Restore project grouping across hosts (#2565)
* fix(projects): restore cross-host grouping Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration. * fix(projects): keep ambiguous clones separate A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate. * fix(projects): preserve host-local project identity * fix(projects): strengthen grouping identity * test(app): update project host shape contract * fix(projects): preserve selected host identity * fix(projects): close grouped routing gaps * fix(projects): stabilize legacy subproject grouping * fix(projects): preserve selected project identity * fix(projects): isolate legacy host identity * fix(projects): preserve symlinked bootstrap roots * fix(projects): reconcile grouped custom names * fix(projects): stabilize settings and path identity * fix(projects): normalize default remote ports * fix(projects): harden host and remote identity * fix(projects): preserve host-scoped actions * fix(projects): preserve remote path semantics * fix(projects): model SCP remote semantics * fix(projects): keep settings on routed host * fix(projects): preserve SSH URL path semantics * fix(projects): separate identity from presentation * test(projects): use grouped identity in UI specs * fix(projects): harden grouped project routing * test(projects): expect framed settings route * fix(projects): preserve exact placement identity * fix(projects): harden grouping identity and routing * fix(projects): restore project key as grouping identity Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping. * fix(projects): prevent project key collisions Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership. * test(projects): assert host-scoped project keys * fix(projects): preserve project key compatibility Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories. * fix(projects): harden project reference identity * fix(projects): retain archived reference errors * fix(projects): scope legacy paths to hosts * test(app): model persisted project keys * fix(projects): retain absolute scp users * fix(projects): resolve legacy mutation aliases * fix(projects): preserve conservative identity boundaries * fix(projects): preserve legacy placement identity * test(projects): assert legacy placement identity * test(projects): expect canonical Windows keys * fix(projects): separate bootstrap identity fields * fix(projects): scope legacy paths by host * fix(projects): stabilize opaque project identity * fix(projects): preserve remaining opaque identities * fix(projects): classify forward-slash UNC roots * fix(projects): preserve opaque selection keys * fix(projects): frame host-local project keys * fix(projects): canonicalize framed identities * test(projects): isolate repeated remote fixtures * fix(projects): keep project key as UI identity * fix(projects): reconcile hydrated project references * fix(projects): preserve archived project identity * refactor(projects): group projects from project records, not workspaces Cross-host grouping had been built by hanging a projectKey off every workspace descriptor. That scoped the key to the wrong entity: the app then had to reconstruct project identity per workspace in six modules, which forced projectKey fallbacks into RPC arguments and a server-side resolver that turned grouping keys back into project ids. A workspace has a projectId. The key belongs to the project, and grouping is a display concern, so the daemon now publishes projects over project.list plus the existing project.update channel and the app groups them in one place. Projects are addressed by host-local projectId everywhere, including the settings route. Projects persisted without a key are backfilled by ordinary boot reconciliation, with no migration. Old daemons advertise no projectList capability and simply do not group. * test(projects): seed projects alongside workspaces in fixtures Sidebar fixtures seeded workspaces without their projects, which the daemon cannot produce now that projects arrive on their own channel, so nothing rendered. Adds a seeding helper that supplies both. Also drops the provisioning test for accepting a project key where a project id belongs; that resolver was removed deliberately. * fix(projects): keep derived project identity stable Four gaps left by shrinking the key derivation: Local keys used a bare path resolve, so one directory reached by a different spelling produced a different key and reset grouped identity. Windows CI caught this with the 8.3 and long-name forms of one temp path. Canonicalize through the shared realpath-aware normalization. Subproject display names lost their selected-path input, so every subproject of a monorepo rendered under the same remote-derived name. Reconciliation only ever backfilled a missing key, so a project whose remote changed stayed grouped with hosts on the old remote. Project settings kept a disconnected host selected and rendered a dead end with no way back to an editable host. * fix(projects): read scheme remotes as URLs, not scp form An `ssh://user@host:22/org/repo` remote matched the scp-form pattern first, because `[^@]+` happily eats `ssh://git`, so the port landed in the path and the project keyed as `remote:host/22/org/repo`. Two spellings of one remote therefore never grouped. Try scp form only when there is no scheme, and drop protocol-default ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
@@ -2,7 +2,7 @@ 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 { test, expect } from "./fixtures";
|
||||
import {
|
||||
addProjectFlow,
|
||||
addProjectFlowBack,
|
||||
@@ -13,36 +13,21 @@ import {
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
openAddProjectFlow,
|
||||
waitForConnectedHost,
|
||||
} from "./helpers/add-project-flow";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import {
|
||||
addConnectedHostAndReload,
|
||||
addOfflineHostAndReload,
|
||||
waitForConnectedHost,
|
||||
} 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";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
test("keyboard selection chooses the second host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, secondaryHost);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
@@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, secondaryHost);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
@@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, secondaryHost);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
|
||||
@@ -207,7 +207,7 @@ test.describe("Composer attachments", () => {
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectId,
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
@@ -293,7 +293,7 @@ test.describe("Composer attachments", () => {
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectId,
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await fillComposerDraft(page, "lock test prompt");
|
||||
|
||||
@@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||
async function removeProjectFromSidebar(page: Page, projectKey: string): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
|
||||
const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
@@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
||||
// user-confirmed removal proceeds deterministically.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
|
||||
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`);
|
||||
await expect(removeItem).toBeVisible({ timeout: 10_000 });
|
||||
await removeItem.click();
|
||||
}
|
||||
@@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
|
||||
|
||||
try {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
|
||||
const newWorkspaceRow = page.getByTestId(
|
||||
`sidebar-project-new-workspace-row-${workspace.projectId}`,
|
||||
`sidebar-project-new-workspace-row-${workspace.projectKey}`,
|
||||
);
|
||||
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
|
||||
|
||||
@@ -195,9 +195,10 @@ test.describe("Project with no workspaces persists", () => {
|
||||
test.describe("Project remove", () => {
|
||||
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
|
||||
let readdedProjectId: string | null = null;
|
||||
|
||||
try {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
@@ -206,7 +207,7 @@ test.describe("Project remove", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await removeProjectFromSidebar(page, workspace.projectId);
|
||||
await removeProjectFromSidebar(page, workspace.projectKey);
|
||||
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
@@ -220,21 +221,24 @@ test.describe("Project remove", () => {
|
||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||
expect(readded.error).toBeNull();
|
||||
expect(readded.project).not.toBeNull();
|
||||
const readdedProjectId = readded.project?.projectId ?? "";
|
||||
readdedProjectId = readded.project?.projectId ?? "";
|
||||
const readdedProjectKey = readded.project?.projectKey ?? "";
|
||||
expect(readdedProjectId).not.toBe(workspace.projectId);
|
||||
expect(readdedProjectKey).toBe(workspace.projectKey);
|
||||
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
|
||||
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
if (readdedProjectId) {
|
||||
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
|
||||
}
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
|
||||
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 });
|
||||
|
||||
@@ -57,6 +57,69 @@ export async function addOfflineHostAndReload(
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function addConnectedHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; port: number; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
await addConnectedHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
|
||||
}
|
||||
|
||||
export async function addConnectedHostsAndReload(
|
||||
page: Page,
|
||||
inputs: Array<{ serverId: string; label: string; port: number }>,
|
||||
options?: { primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const connectedHosts = inputs.map((input) =>
|
||||
buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: `127.0.0.1:${input.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.evaluate(
|
||||
({ hosts, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||
if (primaryLabel && registry[0]) registry[0].label = primaryLabel;
|
||||
for (const host of hosts) {
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
hosts: connectedHosts,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: options?.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
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 openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
paseoHome: string;
|
||||
restart(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -133,6 +134,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
return {
|
||||
serverId,
|
||||
port,
|
||||
paseoHome,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await stopProcess(child);
|
||||
|
||||
@@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "listProjects"
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
@@ -29,6 +30,7 @@ type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
|
||||
|
||||
export interface OpenedProject {
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
projectDisplayName: string;
|
||||
workspaceName: string;
|
||||
@@ -45,10 +47,19 @@ function requireWorkspace(payload: WorkspacePayload) {
|
||||
return payload.workspace;
|
||||
}
|
||||
|
||||
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
|
||||
async function openedProjectFromWorkspace(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspace: WorkspaceDescriptor,
|
||||
): Promise<OpenedProject> {
|
||||
const payload = await client.listProjects();
|
||||
const project = payload.projects.find((candidate) => candidate.projectId === workspace.projectId);
|
||||
if (!project?.projectKey) {
|
||||
throw new Error(`Project ${workspace.projectId} has no project key`);
|
||||
}
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectId: workspace.projectId,
|
||||
projectKey: project.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
@@ -107,7 +118,7 @@ export async function openProjectViaDaemon(
|
||||
source: { kind: "directory", path: repoPath },
|
||||
}),
|
||||
);
|
||||
return openedProjectFromWorkspace(workspace);
|
||||
return openedProjectFromWorkspace(client, workspace);
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromDaemon(
|
||||
@@ -149,7 +160,7 @@ export async function createWorktreeViaDaemon(
|
||||
worktreeSlug: input.slug,
|
||||
});
|
||||
const workspace = requireWorkspace(payload);
|
||||
return openedProjectFromWorkspace(workspace);
|
||||
return openedProjectFromWorkspace(client, workspace);
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceComposer(
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
|
||||
import { openProjectSettings } from "./project-settings";
|
||||
import { waitForSidebarHydration } from "./workspace-ui";
|
||||
|
||||
const PROJECT_VISIBILITY_TIMEOUT = 30_000;
|
||||
|
||||
function projectGroup(page: Page, projectName: string): Locator {
|
||||
return page.getByRole("group", { name: projectName, exact: true });
|
||||
}
|
||||
|
||||
function projectGroupWithWorkspace(
|
||||
page: Page,
|
||||
projectName: string,
|
||||
workspaceName: string,
|
||||
): Locator {
|
||||
return projectGroup(page, projectName).filter({ hasText: workspaceName });
|
||||
}
|
||||
|
||||
export async function openProjectDirectory(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
export async function openProjectDirectoryWithHosts(
|
||||
page: Page,
|
||||
input: {
|
||||
hosts: Array<{ serverId: string; label: string; port: number }>;
|
||||
primaryLabel?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostsAndReload(page, input.hosts, { primaryLabel: input.primaryLabel });
|
||||
for (const host of input.hosts) {
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: host.serverId,
|
||||
endpoint: `localhost:${host.port}`,
|
||||
});
|
||||
}
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
export async function expectProjectContainsWorkspaces(
|
||||
page: Page,
|
||||
input: { projectName: string; workspaceNames: string[] },
|
||||
): Promise<void> {
|
||||
const group = projectGroup(page, input.projectName);
|
||||
await expect(group).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
|
||||
for (const workspaceName of input.workspaceNames) {
|
||||
await expect(group.getByRole("button").filter({ hasText: workspaceName })).toBeVisible({
|
||||
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectSeparateProjects(
|
||||
page: Page,
|
||||
projects: Array<{ projectName: string; workspaceName: string }>,
|
||||
): Promise<void> {
|
||||
for (const project of projects) {
|
||||
await expect(
|
||||
projectGroupWithWorkspace(page, project.projectName, project.workspaceName),
|
||||
).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
|
||||
}
|
||||
}
|
||||
|
||||
export async function beginWorkspaceFromProject(page: Page, projectName: string): Promise<void> {
|
||||
const group = projectGroup(page, projectName);
|
||||
await expect(group).toBeVisible({ timeout: PROJECT_VISIBILITY_TIMEOUT });
|
||||
await group.hover();
|
||||
await group.getByLabel(`Create a new workspace for ${projectName}`).click();
|
||||
await expect(page.getByRole("button", { name: "Workspace project", exact: true })).toContainText(
|
||||
projectName,
|
||||
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||
);
|
||||
}
|
||||
|
||||
export async function selectWorkspaceHost(page: Page, hostName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Host", exact: true }).click();
|
||||
await page.getByRole("button", { name: hostName, exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
|
||||
}
|
||||
|
||||
export async function createWorkspaceWithoutAgent(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectProjectWorkspaceCountForHost(
|
||||
page: Page,
|
||||
input: { projectName: string; hostName: string; count: number },
|
||||
): Promise<void> {
|
||||
const group = projectGroup(page, input.projectName);
|
||||
await expect(group.getByRole("button").filter({ hasText: input.hostName })).toHaveCount(
|
||||
input.count,
|
||||
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||
);
|
||||
}
|
||||
|
||||
export async function renameProject(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Rename project", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name);
|
||||
await page.getByRole("button", { name: "Save project name", exact: true }).click();
|
||||
await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({
|
||||
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openGroupedProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.getByRole("button", { name: "Projects", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
await openProjectSettings(page, projectName);
|
||||
}
|
||||
|
||||
export async function switchProjectSettingsHost(page: Page, hostName: string): Promise<void> {
|
||||
const projectSettings = page.getByRole("main");
|
||||
await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click();
|
||||
await page.getByRole("button", { name: hostName, exact: true }).click();
|
||||
await expect(
|
||||
projectSettings.getByRole("button", { name: "Switch host", exact: true }),
|
||||
).toContainText(hostName);
|
||||
}
|
||||
|
||||
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
|
||||
await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText(
|
||||
projectName,
|
||||
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
|
||||
.first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const testId = await projectRow.getAttribute("data-testid");
|
||||
expect(testId).not.toBeNull();
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
|
||||
const projectId = new URL(page.url()).searchParams.get("projectId");
|
||||
expect(projectId).not.toBeNull();
|
||||
return projectId!;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type SessionRequest = Record<string, unknown> & { type?: string; requestId?: str
|
||||
export interface FakeScheduleHostWorkspace {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
projectDisplayName: string;
|
||||
workspace: Record<string, unknown>;
|
||||
}
|
||||
@@ -120,6 +121,7 @@ export async function buildFakeScheduleHostWorkspace(
|
||||
return {
|
||||
serverId: "schedule-fake-host",
|
||||
projectId,
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
workspace: {
|
||||
...baseWorkspace,
|
||||
@@ -139,6 +141,7 @@ export async function installFakeScheduleHost(input: {
|
||||
port: string;
|
||||
serverId: string;
|
||||
workspace: Record<string, unknown>;
|
||||
project: Pick<FakeScheduleHostWorkspace, "projectId" | "projectKey" | "projectDisplayName">;
|
||||
schedules?: FakeScheduleSummary[];
|
||||
}): Promise<void> {
|
||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||
@@ -154,6 +157,7 @@ export async function installFakeScheduleHost(input: {
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
workspaceMultiplicity: true,
|
||||
projectList: true,
|
||||
projectAdd: true,
|
||||
projectRemove: true,
|
||||
workspaceRecovery: true,
|
||||
@@ -197,6 +201,22 @@ export async function installFakeScheduleHost(input: {
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "project.list.request":
|
||||
ws.send(
|
||||
buildSessionMessage("project.list.response", {
|
||||
requestId,
|
||||
projects: [
|
||||
{
|
||||
projectId: input.project.projectId,
|
||||
projectKey: input.project.projectKey,
|
||||
projectDisplayName: input.project.projectDisplayName,
|
||||
projectRootPath: String(input.workspace.projectRootPath),
|
||||
projectKind: "git",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_agents_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_agents_response", {
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface SeedWorkspaceDescriptor {
|
||||
workspaceDirectory: string;
|
||||
}
|
||||
|
||||
interface SeedProjectDescriptor {
|
||||
projectId: string;
|
||||
projectKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||
@@ -25,15 +30,18 @@ export interface SeedDaemonClient {
|
||||
addProject(cwd: string): Promise<{
|
||||
project: {
|
||||
projectId: string;
|
||||
projectKey?: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
||||
renameProject(projectId: string, customName: string | null): Promise<void>;
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
|
||||
createWorkspace(input: {
|
||||
source:
|
||||
| { kind: "directory"; path: string; projectId?: string }
|
||||
@@ -179,8 +187,10 @@ export interface SeededWorkspace {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
workspaceDirectory: string;
|
||||
/** Stable project identity the daemon groups workspaces under. */
|
||||
/** Host-local identity used by daemon project operations. */
|
||||
projectId: string;
|
||||
/** Opaque cross-host key used by grouped project UI. */
|
||||
projectKey: string;
|
||||
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
||||
projectDisplayName: string;
|
||||
cleanup(): Promise<void>;
|
||||
@@ -208,6 +218,11 @@ export async function seedWorkspace(options: {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||
}
|
||||
const workspace = created.workspace;
|
||||
const listed = await client.listProjects();
|
||||
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
|
||||
if (!descriptor?.projectKey) {
|
||||
throw new Error(`Created project ${workspace.projectId} has no project key`);
|
||||
}
|
||||
return {
|
||||
client,
|
||||
repoPath: project.path,
|
||||
@@ -215,6 +230,7 @@ export async function seedWorkspace(options: {
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
projectId: workspace.projectId,
|
||||
projectKey: descriptor.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
|
||||
@@ -64,6 +64,13 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
|
||||
}
|
||||
const descriptor = (await client.listProjects()).projects.find(
|
||||
(project) => project.projectId === createdWorkspace.workspace?.projectId,
|
||||
);
|
||||
const projectKey = descriptor?.projectKey;
|
||||
if (!projectKey) {
|
||||
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
promptPreview,
|
||||
@@ -75,6 +82,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
||||
workspaceName: createdWorkspace.workspace.name,
|
||||
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
|
||||
projectId: createdWorkspace.workspace.projectId,
|
||||
projectKey,
|
||||
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
@@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
@@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: firstProject.projectId,
|
||||
projectKey: firstProject.projectKey,
|
||||
projectDisplayName: firstProject.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
|
||||
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: secondProject.projectId,
|
||||
projectKey: secondProject.projectKey,
|
||||
projectDisplayName: secondProject.projectDisplayName,
|
||||
});
|
||||
|
||||
|
||||
@@ -179,13 +179,13 @@ test.describe("New workspace entry points", () => {
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Project A's row icon opens New Workspace with A preselected.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: projectA.projectId,
|
||||
projectKey: projectA.projectKey,
|
||||
projectDisplayName: projectA.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
@@ -194,7 +194,9 @@ test.describe("New workspace entry points", () => {
|
||||
// manualProjectKey is what the reused 'new' screen must reset when the next
|
||||
// route-driven navigation targets a different project.
|
||||
await page.getByTestId("new-workspace-project-picker-trigger").click();
|
||||
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
|
||||
const optionC = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${projectC.projectKey}`,
|
||||
);
|
||||
await expect(optionC).toBeVisible({ timeout: 30_000 });
|
||||
await optionC.click();
|
||||
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
|
||||
@@ -203,7 +205,7 @@ test.describe("New workspace entry points", () => {
|
||||
// because the stale manual choice (C) was reset on the route change. If the
|
||||
// reset were missing, the trigger would still read C.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: projectB.projectId,
|
||||
projectKey: projectB.projectKey,
|
||||
projectDisplayName: projectB.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
@@ -226,8 +228,8 @@ test.describe("New workspace entry points", () => {
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open New Workspace for the non-git project via the global button, then
|
||||
// select it in the picker (the per-row icon would preselect it too).
|
||||
@@ -236,7 +238,7 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const nonGitOption = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
|
||||
`new-workspace-project-picker-option-${nonGitProject.projectKey}`,
|
||||
);
|
||||
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
|
||||
await nonGitOption.click();
|
||||
@@ -249,7 +251,7 @@ test.describe("New workspace entry points", () => {
|
||||
// Switching to the git project on the same screen reveals the Isolation row.
|
||||
await trigger.click();
|
||||
const gitOption = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${gitProject.projectId}`,
|
||||
`new-workspace-project-picker-option-${gitProject.projectKey}`,
|
||||
);
|
||||
await expect(gitOption).toBeVisible({ timeout: 30_000 });
|
||||
await gitOption.click();
|
||||
|
||||
@@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => {
|
||||
// so its handler is still registered when we cycle here.
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const localProjectIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
||||
|
||||
@@ -212,9 +213,13 @@ test.describe("New workspace flow", () => {
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
for (const projectId of localProjectIds) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
localProjectIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
@@ -830,6 +835,7 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
localProjectIds.add(openedProject.projectId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
@@ -881,6 +887,7 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
localProjectIds.add(openedProject.projectId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { waitForConnectedHost } from "./helpers/hosts";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
|
||||
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
|
||||
@@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({
|
||||
dialogOpenResult: projectPickerFixture.projectPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: getServerId(),
|
||||
endpoint: `localhost:${getE2EDaemonPort()}`,
|
||||
});
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await openAddProjectFlow(page);
|
||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
@@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({
|
||||
dialogOpenResult: null,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: getServerId(),
|
||||
endpoint: `localhost:${getE2EDaemonPort()}`,
|
||||
});
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await openAddProjectFlow(page);
|
||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
|
||||
@@ -163,6 +163,7 @@ test.describe("Schedules", () => {
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
project: fakeHost,
|
||||
schedules: [
|
||||
buildFakeHostSchedule({
|
||||
id: scheduleId,
|
||||
|
||||
@@ -205,7 +205,7 @@ test.describe("Schedules project target", () => {
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
@@ -245,6 +245,7 @@ test.describe("Schedules project target", () => {
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
project: fakeHost,
|
||||
});
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
@@ -277,6 +278,7 @@ test.describe("Schedules project target", () => {
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
project: fakeHost,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
@@ -313,7 +315,7 @@ test.describe("Schedules project target", () => {
|
||||
await expectSettled(hostTrigger);
|
||||
|
||||
await projectTrigger.click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
@@ -335,8 +337,7 @@ test.describe("Schedules project target", () => {
|
||||
await expectSettled(projectTrigger);
|
||||
|
||||
await projectTrigger.click();
|
||||
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
|
||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectKey}`).click();
|
||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText(/select model/i);
|
||||
@@ -362,7 +363,7 @@ test.describe("Schedules project target", () => {
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(
|
||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
||||
|
||||
@@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => {
|
||||
|
||||
// Both projects are expandable parents — the non-git one is NOT flattened
|
||||
// into a bare workspace link.
|
||||
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Each parent shows both of its workspace rows underneath.
|
||||
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
@@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => {
|
||||
// Both projects show a per-row New workspace icon (revealed on hover): the
|
||||
// git project can branch off a worktree, and the non-git project can add
|
||||
// another workspace because the host supports workspaceMultiplicity.
|
||||
await projectRow(page, gitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
||||
await projectRow(page, gitProject.projectKey).hover();
|
||||
await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await projectRow(page, nonGitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
|
||||
await projectRow(page, nonGitProject.projectKey).hover();
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test as base } from "./fixtures";
|
||||
import {
|
||||
beginWorkspaceFromProject,
|
||||
createWorkspaceWithoutAgent,
|
||||
expectProjectContainsWorkspaces,
|
||||
expectProjectSettingsName,
|
||||
expectProjectWorkspaceCountForHost,
|
||||
expectSeparateProjects,
|
||||
openGroupedProjectSettings,
|
||||
openProjectDirectory,
|
||||
openProjectDirectoryWithHosts,
|
||||
renameProject,
|
||||
selectWorkspaceHost,
|
||||
switchProjectSettingsHost,
|
||||
} from "./helpers/project-grouping";
|
||||
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_LABEL = "Secondary Host";
|
||||
const LEGACY_PRIMARY_HOST_LABEL = "Legacy Primary Host";
|
||||
const LEGACY_SECONDARY_HOST_LABEL = "Legacy Secondary Host";
|
||||
const GROUPED_PROJECT_NAME = "paseo-e2e/grouped-project";
|
||||
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
|
||||
const SUBDIRECTORY = path.join("packages", "app");
|
||||
const REPO_FILES = [{ path: path.join(SUBDIRECTORY, "package.json"), content: "{}\n" }];
|
||||
|
||||
interface HostConnection {
|
||||
serverId: string;
|
||||
label: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface ProjectDirectoryScenario {
|
||||
hosts: HostConnection[];
|
||||
primaryLabel?: string;
|
||||
}
|
||||
|
||||
interface CreatedProject {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
async function createProject(
|
||||
client: SeedDaemonClient,
|
||||
input: {
|
||||
projectPath: string;
|
||||
serverId: string;
|
||||
workspaceName: string;
|
||||
projectName?: string;
|
||||
},
|
||||
): Promise<CreatedProject> {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: input.projectPath },
|
||||
title: input.workspaceName,
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create project on ${input.serverId}`);
|
||||
}
|
||||
if (input.projectName) {
|
||||
await client.renameProject(created.workspace.projectId, input.projectName);
|
||||
}
|
||||
return { projectId: created.workspace.projectId };
|
||||
}
|
||||
|
||||
async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise<void> {
|
||||
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
|
||||
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
for (const project of projects) delete project.projectKey;
|
||||
await writeFile(projectsPath, JSON.stringify(projects));
|
||||
}
|
||||
|
||||
const test = base.extend<{
|
||||
crossHostProject: ProjectDirectoryScenario;
|
||||
reconciledCrossHostProject: ProjectDirectoryScenario;
|
||||
rootAndSubdirectoryProjects: ProjectDirectoryScenario;
|
||||
crossHostSubdirectoryProject: ProjectDirectoryScenario;
|
||||
sameHostClones: ProjectDirectoryScenario;
|
||||
}>({
|
||||
crossHostProject: async ({ page: _page }, provide) => {
|
||||
const secondaryHost = await startIsolatedHostDaemon("project-grouping-secondary");
|
||||
const primaryRepo = await createTempGitRepo("grouped-primary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const primaryClient = await connectSeedClient();
|
||||
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
|
||||
let primary: CreatedProject | null = null;
|
||||
let secondary: CreatedProject | null = null;
|
||||
|
||||
try {
|
||||
primary = await createProject(primaryClient, {
|
||||
projectPath: primaryRepo.path,
|
||||
serverId: getServerId(),
|
||||
workspaceName: "Primary workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
secondary = await createProject(secondaryClient, {
|
||||
projectPath: secondaryRepo.path,
|
||||
serverId: secondaryHost.serverId,
|
||||
workspaceName: "Secondary workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await provide({
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
hosts: [
|
||||
{
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
|
||||
if (secondary)
|
||||
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
|
||||
await primaryClient.close().catch(() => undefined);
|
||||
await secondaryClient.close().catch(() => undefined);
|
||||
await primaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryHost.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
|
||||
reconciledCrossHostProject: async ({ page: _page }, provide) => {
|
||||
const primaryHost = await startIsolatedHostDaemon("project-grouping-legacy-primary");
|
||||
const secondaryHost = await startIsolatedHostDaemon("project-grouping-legacy-secondary");
|
||||
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const primaryClient = await connectSeedClient({ port: primaryHost.port });
|
||||
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
|
||||
|
||||
try {
|
||||
await createProject(primaryClient, {
|
||||
projectPath: primaryRepo.path,
|
||||
serverId: primaryHost.serverId,
|
||||
workspaceName: "Recovered primary workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await createProject(secondaryClient, {
|
||||
projectPath: secondaryRepo.path,
|
||||
serverId: secondaryHost.serverId,
|
||||
workspaceName: "Recovered secondary workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await primaryClient.close();
|
||||
await secondaryClient.close();
|
||||
await removePersistedProjectKeys(primaryHost);
|
||||
await removePersistedProjectKeys(secondaryHost);
|
||||
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
|
||||
|
||||
await provide({
|
||||
hosts: [
|
||||
{
|
||||
serverId: primaryHost.serverId,
|
||||
label: LEGACY_PRIMARY_HOST_LABEL,
|
||||
port: primaryHost.port,
|
||||
},
|
||||
{
|
||||
serverId: secondaryHost.serverId,
|
||||
label: LEGACY_SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await primaryClient.close().catch(() => undefined);
|
||||
await secondaryClient.close().catch(() => undefined);
|
||||
await primaryHost.close().catch(() => undefined);
|
||||
await secondaryHost.close().catch(() => undefined);
|
||||
await primaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryRepo.cleanup().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
|
||||
rootAndSubdirectoryProjects: async ({ page: _page }, provide) => {
|
||||
const repo = await createTempGitRepo("grouped-root-subdir-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
files: REPO_FILES,
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
const projects: CreatedProject[] = [];
|
||||
|
||||
try {
|
||||
projects.push(
|
||||
await createProject(client, {
|
||||
projectPath: repo.path,
|
||||
serverId: getServerId(),
|
||||
workspaceName: "Repository root workspace",
|
||||
projectName: "Repository root project",
|
||||
}),
|
||||
);
|
||||
projects.push(
|
||||
await createProject(client, {
|
||||
projectPath: path.join(repo.path, SUBDIRECTORY),
|
||||
serverId: getServerId(),
|
||||
workspaceName: "App package workspace",
|
||||
projectName: "App package project",
|
||||
}),
|
||||
);
|
||||
await provide({ hosts: [] });
|
||||
} finally {
|
||||
for (const project of projects) {
|
||||
await client.removeProject(project.projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
|
||||
crossHostSubdirectoryProject: async ({ page: _page }, provide) => {
|
||||
const secondaryHost = await startIsolatedHostDaemon("project-grouping-subdir-secondary");
|
||||
const primaryRepo = await createTempGitRepo("grouped-subdir-primary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
files: REPO_FILES,
|
||||
});
|
||||
const secondaryRepo = await createTempGitRepo("grouped-subdir-secondary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
files: REPO_FILES,
|
||||
});
|
||||
const primaryClient = await connectSeedClient();
|
||||
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
|
||||
let primary: CreatedProject | null = null;
|
||||
let secondary: CreatedProject | null = null;
|
||||
|
||||
try {
|
||||
primary = await createProject(primaryClient, {
|
||||
projectPath: path.join(primaryRepo.path, SUBDIRECTORY),
|
||||
serverId: getServerId(),
|
||||
workspaceName: "Primary app workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
secondary = await createProject(secondaryClient, {
|
||||
projectPath: path.join(secondaryRepo.path, SUBDIRECTORY),
|
||||
serverId: secondaryHost.serverId,
|
||||
workspaceName: "Secondary app workspace",
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await provide({
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
hosts: [
|
||||
{
|
||||
serverId: secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: secondaryHost.port,
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
|
||||
if (secondary)
|
||||
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
|
||||
await primaryClient.close().catch(() => undefined);
|
||||
await secondaryClient.close().catch(() => undefined);
|
||||
await primaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryHost.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
|
||||
sameHostClones: async ({ page: _page }, provide) => {
|
||||
const firstRepo = await createTempGitRepo("grouped-clone-first-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const secondRepo = await createTempGitRepo("grouped-clone-second-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
const projects: CreatedProject[] = [];
|
||||
|
||||
try {
|
||||
projects.push(
|
||||
await createProject(client, {
|
||||
projectPath: firstRepo.path,
|
||||
serverId: getServerId(),
|
||||
workspaceName: "First clone workspace",
|
||||
projectName: "First clone",
|
||||
}),
|
||||
);
|
||||
projects.push(
|
||||
await createProject(client, {
|
||||
projectPath: secondRepo.path,
|
||||
serverId: getServerId(),
|
||||
workspaceName: "Second clone workspace",
|
||||
projectName: "Second clone",
|
||||
}),
|
||||
);
|
||||
await provide({ hosts: [] });
|
||||
} finally {
|
||||
for (const project of projects) {
|
||||
await client.removeProject(project.projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await firstRepo.cleanup().catch(() => undefined);
|
||||
await secondRepo.cleanup().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function openScenario(
|
||||
page: Parameters<typeof openProjectDirectory>[0],
|
||||
scenario: ProjectDirectoryScenario,
|
||||
): Promise<void> {
|
||||
if (scenario.hosts.length === 0) {
|
||||
await openProjectDirectory(page);
|
||||
return;
|
||||
}
|
||||
await openProjectDirectoryWithHosts(page, scenario);
|
||||
}
|
||||
|
||||
test.describe("Sidebar project grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("groups projects with the same Git remote across hosts", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await expectProjectContainsWorkspaces(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
workspaceNames: ["Primary workspace", "Secondary workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("groups persisted projects missing project keys after boot", async ({
|
||||
page,
|
||||
reconciledCrossHostProject,
|
||||
}) => {
|
||||
await openScenario(page, reconciledCrossHostProject);
|
||||
await expectProjectContainsWorkspaces(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
workspaceNames: ["Recovered primary workspace", "Recovered secondary workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a repository root and its subdirectory as separate projects", async ({
|
||||
page,
|
||||
rootAndSubdirectoryProjects,
|
||||
}) => {
|
||||
await openScenario(page, rootAndSubdirectoryProjects);
|
||||
await expectSeparateProjects(page, [
|
||||
{ projectName: "Repository root project", workspaceName: "Repository root workspace" },
|
||||
{ projectName: "App package project", workspaceName: "App package workspace" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("groups the same repository subdirectory across hosts", async ({
|
||||
page,
|
||||
crossHostSubdirectoryProject,
|
||||
}) => {
|
||||
await openScenario(page, crossHostSubdirectoryProject);
|
||||
await expectProjectContainsWorkspaces(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
workspaceNames: ["Primary app workspace", "Secondary app workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps two clones of the same repository on one host separate", async ({
|
||||
page,
|
||||
sameHostClones,
|
||||
}) => {
|
||||
await openScenario(page, sameHostClones);
|
||||
await expectSeparateProjects(page, [
|
||||
{ projectName: "First clone", workspaceName: "First clone workspace" },
|
||||
{ projectName: "Second clone", workspaceName: "Second clone workspace" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME);
|
||||
await renameProject(page, "Primary-only project name");
|
||||
await expectProjectSettingsName(page, "Primary-only project name");
|
||||
await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL);
|
||||
await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
|
||||
});
|
||||
|
||||
test("creates a workspace on the selected host from a grouped project", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
|
||||
await selectWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
await createWorkspaceWithoutAgent(page);
|
||||
await expectProjectWorkspaceCountForHost(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
|
||||
await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click();
|
||||
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
||||
|
||||
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
|
||||
@@ -32,8 +32,12 @@ interface RestartDaemonClient {
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceDirectory: string;
|
||||
projectId: string;
|
||||
}>;
|
||||
}>;
|
||||
listProjects(): Promise<{
|
||||
projects: Array<{ projectId: string; projectKey?: string }>;
|
||||
}>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
agent: {
|
||||
@@ -461,9 +465,16 @@ test.describe("Workspace model restart regressions", () => {
|
||||
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
||||
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
|
||||
|
||||
const reconciledProjectKey = (await client.listProjects()).projects.find(
|
||||
(project) => project.projectId === seeded.projectId,
|
||||
);
|
||||
if (!reconciledProjectKey?.projectKey) {
|
||||
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
|
||||
}
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: reconciledProjectKey.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
|
||||
@@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
try {
|
||||
const project = {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
try {
|
||||
const project = {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
};
|
||||
|
||||
@@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
try {
|
||||
const project = {
|
||||
projectKey: seeded.projectId,
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
};
|
||||
|
||||
@@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
// Model B: a non-git project is an expandable parent like any other, with
|
||||
// its single workspace already rendered as its own row underneath.
|
||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
|
||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
|
||||
@@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
// Both the original and the new workspace render as distinct rows under
|
||||
// the same expandable parent.
|
||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
|
||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
|
||||
|
||||
@@ -883,7 +883,7 @@ function RootStack() {
|
||||
<Stack.Screen name="settings/index" />
|
||||
<Stack.Screen name="settings/[section]" />
|
||||
<Stack.Screen name="settings/projects/index" />
|
||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
||||
<Stack.Screen name="settings/projects/[serverId]/[projectId]" />
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default function SettingsProjectDetailRoute() {
|
||||
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
|
||||
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
|
||||
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
|
||||
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
|
||||
|
||||
return <SettingsScreen view={view} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsProjectDetailRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
projectId?: string | string[];
|
||||
}>();
|
||||
const serverId = normalizeProjectSettingsRouteId(params.serverId);
|
||||
const projectId = normalizeProjectSettingsRouteId(params.projectId);
|
||||
const view = useMemo(
|
||||
() => ({ kind: "project" as const, serverId, projectId }),
|
||||
[projectId, serverId],
|
||||
);
|
||||
|
||||
return <SettingsScreen view={view} />;
|
||||
}
|
||||
@@ -365,7 +365,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const recommendedPaths = useRecommendedProjectPaths(hostId);
|
||||
const openProject = useOpenProject(hostId);
|
||||
const cloneGithubProject = useCloneGithubProject(hostId);
|
||||
const addEmptyProject = useSessionStore((store) => store.addEmptyProject);
|
||||
const upsertProject = useSessionStore((store) => store.upsertProject);
|
||||
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const submissionInFlightRef = useRef(false);
|
||||
@@ -735,7 +735,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
registerProjectDescriptor({
|
||||
serverId: page.hostId,
|
||||
project: payload.project,
|
||||
addEmptyProject,
|
||||
upsertProject,
|
||||
setHasHydratedWorkspaces,
|
||||
});
|
||||
openNewWorkspaceForProject(page.hostId, payload.project);
|
||||
@@ -749,7 +749,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
} finally {
|
||||
submissionInFlightRef.current = false;
|
||||
}
|
||||
}, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]);
|
||||
}, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]);
|
||||
|
||||
const submitActive = useCallback(() => {
|
||||
if (page.kind === "new-directory-name") {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -164,9 +165,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
act(() => {
|
||||
setHostProfiles([makeHost()]);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: ["project-a", "project-b"],
|
||||
|
||||
@@ -115,6 +115,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import {
|
||||
buildSidebarProjectRowModel,
|
||||
resolveSidebarProjectIconTarget,
|
||||
resolveSidebarProjectLocalPath,
|
||||
type SidebarProjectHostTarget,
|
||||
} from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
@@ -132,6 +133,7 @@ import {
|
||||
} from "@/constants/platform";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
||||
|
||||
@@ -505,6 +507,9 @@ function ProjectRowTrailingActions({
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
}) {
|
||||
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
||||
const localDaemonServerId = useLocalDaemonServerId();
|
||||
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
|
||||
const settingsTarget = project.hosts[0] ?? null;
|
||||
return (
|
||||
<View style={styles.projectTrailingActions}>
|
||||
{worktreeTarget ? (
|
||||
@@ -523,7 +528,8 @@ function ProjectRowTrailingActions({
|
||||
>
|
||||
<ProjectKebabMenu
|
||||
projectKey={project.projectKey}
|
||||
projectPath={project.iconWorkingDir}
|
||||
settingsTarget={settingsTarget}
|
||||
projectPath={localProjectPath}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
/>
|
||||
@@ -550,11 +556,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
|
||||
function ProjectKebabMenu({
|
||||
projectKey,
|
||||
settingsTarget,
|
||||
projectPath,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
}: {
|
||||
projectKey: string;
|
||||
settingsTarget: { serverId: string; projectId: string } | null;
|
||||
projectPath: string;
|
||||
onRemoveProject: () => void;
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
@@ -562,10 +570,10 @@ function ProjectKebabMenu({
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const handleOpenProjectSettings = useCallback(() => {
|
||||
if (projectKey.trim().length === 0) return;
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
const canOpenProjectSettings = projectKey.trim().length > 0;
|
||||
if (!settingsTarget) return;
|
||||
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
|
||||
}, [settingsTarget]);
|
||||
const canOpenProjectSettings = settingsTarget !== null;
|
||||
// Desktop-only: open a second window that lands on this project via the same
|
||||
// open-project flow as a CLI launch. The project stays visible here too — no
|
||||
// ownership, no move.
|
||||
@@ -908,10 +916,10 @@ function NewWorkspaceGhostRow({
|
||||
serverId: worktreeTarget.serverId,
|
||||
sourceDirectory: worktreeTarget.iconWorkingDir,
|
||||
displayName,
|
||||
projectId: project.projectKey,
|
||||
projectId: worktreeTarget.projectId,
|
||||
}) as Href,
|
||||
);
|
||||
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
|
||||
}, [displayName, onWorkspacePress, worktreeTarget]);
|
||||
const rowStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.newWorkspaceGhostRow,
|
||||
@@ -988,10 +996,10 @@ function ProjectHeaderRow({
|
||||
serverId: worktreeTarget.serverId,
|
||||
sourceDirectory: worktreeTarget.iconWorkingDir,
|
||||
displayName,
|
||||
projectId: project.projectKey,
|
||||
projectId: worktreeTarget.projectId,
|
||||
}) as Href,
|
||||
);
|
||||
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
|
||||
}, [displayName, onWorkspacePress, worktreeTarget]);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
@@ -1755,7 +1763,6 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
void removeProjectFromHosts({
|
||||
projectKey: project.projectKey,
|
||||
targets: readiness.targets,
|
||||
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
|
||||
})
|
||||
@@ -1824,7 +1831,7 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
<View role="group" accessibilityLabel={displayName} style={styles.projectBlock}>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
|
||||
@@ -43,6 +43,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt
|
||||
hosts: [
|
||||
{
|
||||
serverId: "srv",
|
||||
projectId: "project",
|
||||
iconWorkingDir: "/repo",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
|
||||
@@ -299,7 +299,14 @@ export function ComboboxItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable testID={testID} disabled={disabled} onPress={onPress} style={itemPressableStyle}>
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={itemPressableStyle}
|
||||
>
|
||||
{leadingContent}
|
||||
<View style={itemContentStyle}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
@@ -93,7 +94,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
act(() => {
|
||||
setHostProfiles([hostProfile()]);
|
||||
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(
|
||||
seedSessionWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
|
||||
@@ -139,7 +140,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
it("publishes status-mode shortcut targets in visual status order", async () => {
|
||||
act(() => {
|
||||
useSidebarViewStore.getState().setGroupMode("status");
|
||||
useSessionStore.getState().setWorkspaces(
|
||||
seedSessionWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
[
|
||||
@@ -211,18 +212,14 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
|
||||
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(
|
||||
"host-a",
|
||||
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
|
||||
);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(
|
||||
"host-b",
|
||||
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
|
||||
);
|
||||
seedSessionWorkspaces(
|
||||
"host-a",
|
||||
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
|
||||
);
|
||||
seedSessionWorkspaces(
|
||||
"host-b",
|
||||
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
|
||||
);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
||||
useSidebarViewStore.getState().toggleHostFilter("host-b");
|
||||
|
||||
@@ -3,101 +3,56 @@ import {
|
||||
buildWorktreeSetupCalloutPolicy,
|
||||
selectActiveGitWorkspaceProject,
|
||||
shouldShowWorktreeSetupCallout,
|
||||
type WorktreeSetupWorkspaceInput,
|
||||
} from "./worktree-setup-callout-policy";
|
||||
|
||||
function gitWorkspace(
|
||||
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
|
||||
): WorktreeSetupWorkspaceInput {
|
||||
return {
|
||||
projectId: "project-1",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project-1",
|
||||
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectActiveGitWorkspaceProject", () => {
|
||||
it("selects the active git workspace project from checkout metadata", () => {
|
||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/main-project-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the workspace project root when checkout metadata has no main root", () => {
|
||||
it("selects the host-local project id for a git workspace", () => {
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject(
|
||||
"server-1",
|
||||
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
|
||||
),
|
||||
selectActiveGitWorkspaceProject("server-1", {
|
||||
projectId: "prj_local",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project",
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/project-1",
|
||||
projectId: "prj_local",
|
||||
repoRoot: "/repo/project",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores non-git workspaces and blank project coordinates", () => {
|
||||
it("ignores non-git workspaces and blank coordinates", () => {
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
|
||||
).toBe(null);
|
||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject(
|
||||
"server-1",
|
||||
gitWorkspace({ projectRootPath: " ", project: null }),
|
||||
),
|
||||
).toBe(null);
|
||||
selectActiveGitWorkspaceProject("server-1", {
|
||||
projectId: "project",
|
||||
projectKind: "directory",
|
||||
projectRootPath: "/repo",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowWorktreeSetupCallout", () => {
|
||||
it("shows the callout when paseo config was read and setup commands are missing", () => {
|
||||
it("shows only after a successful config read with no setup command", () => {
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show the callout when setup commands are present", () => {
|
||||
expect(
|
||||
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
|
||||
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm i" } } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowWorktreeSetupCallout({
|
||||
ok: true,
|
||||
config: { worktree: { setup: [" ", "npm install"] } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show the callout when reading paseo config fails or has not completed", () => {
|
||||
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorktreeSetupCalloutPolicy", () => {
|
||||
it("builds the stable sidebar callout identity and action route", () => {
|
||||
it("routes by server id and project id", () => {
|
||||
expect(
|
||||
buildWorktreeSetupCalloutPolicy({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/project-1",
|
||||
projectId: "prj_local",
|
||||
repoRoot: "/repo/project",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "worktree-setup-missing:project-1",
|
||||
dismissalKey: "worktree-setup-missing:project-1",
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
actionLabel: "Open project settings",
|
||||
projectSettingsRoute: "/settings/projects/project-1",
|
||||
testID: "worktree-setup-callout-project-1",
|
||||
).toMatchObject({
|
||||
id: "worktree-setup-missing:server-1:prj_local",
|
||||
projectSettingsRoute: "/settings/projects/server-1/prj_local",
|
||||
testID: "worktree-setup-callout-prj_local",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,16 +6,11 @@ export interface WorktreeSetupWorkspaceInput {
|
||||
projectId: string;
|
||||
projectKind: string;
|
||||
projectRootPath: string;
|
||||
project?: {
|
||||
checkout?: {
|
||||
mainRepoRoot?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ActiveGitWorkspaceProject {
|
||||
serverId: string;
|
||||
projectKey: string;
|
||||
projectId: string;
|
||||
repoRoot: string;
|
||||
}
|
||||
|
||||
@@ -43,13 +38,13 @@ export function selectActiveGitWorkspaceProject(
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectKey = workspace.projectId.trim();
|
||||
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
|
||||
if (!projectKey || !repoRoot) {
|
||||
const projectId = workspace.projectId;
|
||||
const repoRoot = workspace.projectRootPath.trim();
|
||||
if (!projectId.trim() || !repoRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, projectKey, repoRoot };
|
||||
return { serverId, projectId, repoRoot };
|
||||
}
|
||||
|
||||
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
|
||||
@@ -59,7 +54,7 @@ export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResu
|
||||
export function buildWorktreeSetupCalloutPolicy(
|
||||
project: ActiveGitWorkspaceProject,
|
||||
): WorktreeSetupCalloutPolicy {
|
||||
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
|
||||
const calloutKey = `worktree-setup-missing:${project.serverId}:${project.projectId}`;
|
||||
|
||||
return {
|
||||
id: calloutKey,
|
||||
@@ -68,8 +63,8 @@ export function buildWorktreeSetupCalloutPolicy(
|
||||
title: i18n.t("sidebar.worktreeSetup.title"),
|
||||
description: i18n.t("sidebar.worktreeSetup.description"),
|
||||
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
|
||||
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
|
||||
testID: `worktree-setup-callout-${project.projectKey}`,
|
||||
projectSettingsRoute: buildProjectSettingsRoute(project.serverId, project.projectId),
|
||||
testID: `worktree-setup-callout-${project.projectId}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
|
||||
export function WorktreeSetupCalloutSource() {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const activeProject = useWorkspaceFields(
|
||||
const selectedWorkspaceProject = useWorkspaceFields(
|
||||
selection?.serverId ?? null,
|
||||
selection?.workspaceId ?? null,
|
||||
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
|
||||
);
|
||||
const activeProject = selectedWorkspaceProject;
|
||||
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
|
||||
const callouts = useSidebarCallouts();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -4,10 +4,7 @@ import type {
|
||||
ProjectAddResponse,
|
||||
WorkspaceProjectDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
|
||||
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store";
|
||||
|
||||
type OpenProjectPayload = ProjectAddResponse["payload"];
|
||||
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
|
||||
@@ -47,28 +44,28 @@ export interface OpenProjectDirectlyInput {
|
||||
isConnected: boolean;
|
||||
canAddProject: boolean;
|
||||
client: Pick<DaemonClient, "addProject"> | null;
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
}
|
||||
|
||||
interface ProjectRegistrationCallbacks {
|
||||
serverId: string;
|
||||
isConnected: boolean;
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
}
|
||||
|
||||
export interface RegisterProjectDescriptorInput {
|
||||
serverId: string;
|
||||
project: WorkspaceProjectDescriptorPayload;
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => 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.upsertProject(serverId, normalizeProjectDescriptor(input.project));
|
||||
input.setHasHydratedWorkspaces(serverId, true);
|
||||
return true;
|
||||
}
|
||||
@@ -109,7 +106,7 @@ export async function openProjectDirectly(
|
||||
const registered = registerProjectDescriptor({
|
||||
serverId: normalizedServerId,
|
||||
project: payload.project,
|
||||
addEmptyProject: input.addEmptyProject,
|
||||
upsertProject: input.upsertProject,
|
||||
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
|
||||
});
|
||||
return registered
|
||||
@@ -145,7 +142,7 @@ export async function cloneGithubProjectDirectly(
|
||||
const registered = registerProjectDescriptor({
|
||||
serverId: normalizedServerId,
|
||||
project: payload.project,
|
||||
addEmptyProject: input.addEmptyProject,
|
||||
upsertProject: input.upsertProject,
|
||||
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
|
||||
});
|
||||
return registered
|
||||
|
||||
@@ -78,20 +78,25 @@ function project(input: {
|
||||
projectKind?: WorkspaceStructureProject["projectKind"];
|
||||
iconWorkingDir?: string;
|
||||
workspaceKeys: string[];
|
||||
hosts?: WorkspaceStructureProject["hosts"];
|
||||
hosts?: Array<
|
||||
Omit<WorkspaceStructureProject["hosts"][number], "projectId"> & { projectId?: string }
|
||||
>;
|
||||
}): WorkspaceStructureProject {
|
||||
return {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName ?? input.projectKey,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
hosts: input.hosts ?? [
|
||||
{
|
||||
serverId: "srv",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
hosts: Array.from(
|
||||
input.hosts ?? [
|
||||
{
|
||||
serverId: "srv",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
(host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }),
|
||||
),
|
||||
workspaceKeys: input.workspaceKeys,
|
||||
};
|
||||
}
|
||||
@@ -324,8 +329,18 @@ describe("shared sidebar workspace model", () => {
|
||||
expect.objectContaining({
|
||||
projectKey: "getpaseo/paseo",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
{
|
||||
serverId: "host-a",
|
||||
projectId: "getpaseo/paseo",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
projectId: "getpaseo/paseo",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
expect.objectContaining({
|
||||
@@ -401,6 +416,35 @@ describe("shared sidebar workspace model", () => {
|
||||
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
|
||||
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
|
||||
});
|
||||
|
||||
it("keeps a structurally disambiguated project key in status entries", () => {
|
||||
const projectKey = "host:srv:project:prj_a";
|
||||
const model = buildSidebarWorkspacePlacementModel({
|
||||
projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })],
|
||||
});
|
||||
const entries = buildSidebarWorkspaceEntries({
|
||||
placements: model.workspaces,
|
||||
sessions: [
|
||||
{
|
||||
serverId: "srv",
|
||||
workspaceAgentActivity: new Map(),
|
||||
workspaces: new Map([
|
||||
[
|
||||
"clone-a",
|
||||
workspace({
|
||||
id: "clone-a",
|
||||
name: "main",
|
||||
projectId: "prj_a",
|
||||
projectDisplayName: "acme/app",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowSidebarHostLabels", () => {
|
||||
|
||||
@@ -141,10 +141,11 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
|
||||
export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
projectKey?: string;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): SidebarWorkspaceEntry {
|
||||
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
||||
const projectKey = input.projectKey ?? input.workspace.projectId;
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
@@ -323,6 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
|
||||
const entry = createSidebarWorkspaceEntry({
|
||||
serverId: placement.serverId,
|
||||
workspace,
|
||||
projectKey: placement.projectKey,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getOpenProjectFailureReason,
|
||||
openProjectDirectly,
|
||||
} from "@/hooks/open-project";
|
||||
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
|
||||
import type { ProjectDescriptor } from "@/stores/session-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const PROJECT_PATH = "/repo/project";
|
||||
@@ -20,7 +20,7 @@ function buildProjectPayload() {
|
||||
|
||||
interface RecordedProject {
|
||||
serverId: string;
|
||||
project: ProjectWithoutWorkspacesDescriptor;
|
||||
project: ProjectDescriptor;
|
||||
}
|
||||
|
||||
interface RecordedHydrated {
|
||||
@@ -40,7 +40,7 @@ function createFakeSession() {
|
||||
return {
|
||||
projects,
|
||||
hydrated,
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => {
|
||||
projects.push({ serverId, project });
|
||||
},
|
||||
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
|
||||
@@ -83,7 +83,7 @@ describe("openProjectDirectly", () => {
|
||||
project: projectPayload,
|
||||
}),
|
||||
},
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("openProjectDirectly", () => {
|
||||
serverId: SERVER_ID,
|
||||
project: {
|
||||
projectId: "project-1",
|
||||
projectKey: null,
|
||||
projectDisplayName: "project",
|
||||
projectCustomName: null,
|
||||
projectKind: "git",
|
||||
@@ -117,7 +118,7 @@ describe("openProjectDirectly", () => {
|
||||
project: buildProjectPayload(),
|
||||
}),
|
||||
},
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
@@ -146,7 +147,7 @@ describe("openProjectDirectly", () => {
|
||||
project: null,
|
||||
}),
|
||||
},
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
@@ -173,7 +174,7 @@ describe("cloneGithubProjectDirectly", () => {
|
||||
cloneProtocol: "https",
|
||||
isConnected: true,
|
||||
client: github,
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
@@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
|
||||
project: {
|
||||
...projectPayload,
|
||||
projectCustomName: null,
|
||||
projectKey: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -208,7 +210,7 @@ describe("cloneGithubProjectDirectly", () => {
|
||||
cloneProtocol: "https",
|
||||
isConnected: true,
|
||||
client: github,
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
upsertProject: session.upsertProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useOpenProject(
|
||||
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
|
||||
: false,
|
||||
);
|
||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||
const upsertProject = useSessionStore((state) => state.upsertProject);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
@@ -31,13 +31,13 @@ export function useOpenProject(
|
||||
isConnected,
|
||||
canAddProject,
|
||||
client,
|
||||
addEmptyProject,
|
||||
upsertProject,
|
||||
setHasHydratedWorkspaces,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
[
|
||||
addEmptyProject,
|
||||
upsertProject,
|
||||
canAddProject,
|
||||
client,
|
||||
isConnected,
|
||||
@@ -57,7 +57,7 @@ export function useCloneGithubProject(
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||
const upsertProject = useSessionStore((state) => state.upsertProject);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
@@ -69,10 +69,10 @@ export function useCloneGithubProject(
|
||||
...(cloneProtocol ? { cloneProtocol } : {}),
|
||||
isConnected,
|
||||
client,
|
||||
addEmptyProject,
|
||||
upsertProject,
|
||||
setHasHydratedWorkspaces,
|
||||
});
|
||||
},
|
||||
[addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces],
|
||||
[client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,242 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { ProjectDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
deriveProjectsFromReplica,
|
||||
type ProjectHostReplica,
|
||||
type ProjectHostRuntimeState,
|
||||
} from "@/hooks/use-projects";
|
||||
|
||||
function runtimeState(input: {
|
||||
serverId: string;
|
||||
isOnline: boolean;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
error?: string | null;
|
||||
}): ProjectHostRuntimeState {
|
||||
function project(id: string, name: string, root: string): ProjectDescriptor {
|
||||
return {
|
||||
projectId: id,
|
||||
projectKey: `remote:github.com/${name}`,
|
||||
projectDisplayName: name,
|
||||
projectCustomName: null,
|
||||
projectRootPath: root,
|
||||
projectKind: "git",
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeState(input: Partial<ProjectHostRuntimeState> & { serverId: string }) {
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
isOnline: input.isOnline,
|
||||
isOnline: input.isOnline ?? true,
|
||||
isLoading: input.isLoading ?? false,
|
||||
isFetching: input.isFetching ?? false,
|
||||
error: input.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectKey,
|
||||
projectDisplayName: input.projectName,
|
||||
projectCustomName: null,
|
||||
projectRootPath: input.cwd,
|
||||
workspaceDirectory: input.cwd,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
title: null,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: {
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
},
|
||||
githubRuntime: null,
|
||||
project: {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName,
|
||||
checkout: {
|
||||
cwd: input.cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
worktreeRoot: input.cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyProject(input: {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
}): EmptyProjectDescriptor {
|
||||
return {
|
||||
projectId: input.projectKey,
|
||||
projectDisplayName: input.projectName,
|
||||
projectCustomName: null,
|
||||
projectRootPath: input.cwd,
|
||||
projectKind: "git",
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveProjectsFromReplica", () => {
|
||||
it("aggregates store workspaces and empty projects sorted by display name", () => {
|
||||
it("derives projects from the project descriptor replica", () => {
|
||||
const replicas: ProjectHostReplica[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "z-main",
|
||||
projectKey: "remote:github.com/acme/zeta",
|
||||
projectName: "acme/zeta",
|
||||
cwd: "/repo/zeta",
|
||||
remoteUrl: "https://github.com/acme/zeta.git",
|
||||
}),
|
||||
],
|
||||
emptyProjects: [],
|
||||
workspaces: [],
|
||||
projects: [project("prj_zeta", "acme/zeta", "/repo/zeta")],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
workspaces: [],
|
||||
emptyProjects: [
|
||||
emptyProject({
|
||||
projectKey: "remote:github.com/acme/alpha",
|
||||
projectName: "acme/alpha",
|
||||
cwd: "/repo/alpha",
|
||||
}),
|
||||
],
|
||||
projects: [project("prj_alpha", "acme/alpha", "/repo/alpha")],
|
||||
},
|
||||
];
|
||||
|
||||
const result = deriveProjectsFromReplica({
|
||||
replicas,
|
||||
runtimeStates: [
|
||||
runtimeState({ serverId: "local", isOnline: true }),
|
||||
runtimeState({ serverId: "local" }),
|
||||
runtimeState({ serverId: "laptop", isOnline: false }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects.map((project) => project.projectName)).toEqual([
|
||||
"acme/alpha",
|
||||
"acme/zeta",
|
||||
]);
|
||||
expect(result.projects[0]?.hosts).toEqual([
|
||||
expect.objectContaining({
|
||||
serverId: "laptop",
|
||||
isOnline: false,
|
||||
repoRoot: "/repo/alpha",
|
||||
workspaceCount: 0,
|
||||
}),
|
||||
]);
|
||||
expect(result.hostErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps runtime directory status into loading and host errors", () => {
|
||||
const replicas: ProjectHostReplica[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
emptyProjects: [],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
workspaces: [],
|
||||
emptyProjects: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = deriveProjectsFromReplica({
|
||||
replicas,
|
||||
runtimeStates: [
|
||||
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
|
||||
runtimeState({
|
||||
serverId: "laptop",
|
||||
isOnline: true,
|
||||
isLoading: true,
|
||||
error: "laptop unavailable",
|
||||
}),
|
||||
],
|
||||
expect(result.projects.map((item) => item.projectName)).toEqual(["acme/alpha", "acme/zeta"]);
|
||||
expect(result.projects[0]?.hosts[0]).toMatchObject({
|
||||
serverId: "laptop",
|
||||
projectId: "prj_alpha",
|
||||
repoRoot: "/repo/alpha",
|
||||
workspaceCount: 0,
|
||||
});
|
||||
|
||||
expect(result.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
expect(result.hostErrors).toEqual([
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
message: "laptop unavailable",
|
||||
},
|
||||
]);
|
||||
expect(result.isLoading).toBe(true);
|
||||
expect(result.isFetching).toBe(true);
|
||||
});
|
||||
|
||||
it("returns only the stable public project and host entry shapes", () => {
|
||||
it("preserves runtime loading and error state", () => {
|
||||
const result = deriveProjectsFromReplica({
|
||||
replicas: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
emptyProjects: [],
|
||||
workspaces: [],
|
||||
projects: [project("prj_app", "acme/app", "/repo/app")],
|
||||
},
|
||||
],
|
||||
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
|
||||
runtimeStates: [
|
||||
runtimeState({
|
||||
serverId: "local",
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: "unavailable",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
"projectCustomName",
|
||||
"projectKey",
|
||||
"projectName",
|
||||
"totalWorkspaceCount",
|
||||
]);
|
||||
expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
|
||||
"gitRuntime",
|
||||
"githubRuntime",
|
||||
"isOnline",
|
||||
"repoRoot",
|
||||
"serverId",
|
||||
"serverName",
|
||||
"workspaceCount",
|
||||
"workspaces",
|
||||
]);
|
||||
expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
|
||||
"currentBranch",
|
||||
"id",
|
||||
"name",
|
||||
"status",
|
||||
"workspaceKind",
|
||||
expect(result.isLoading).toBe(true);
|
||||
expect(result.isFetching).toBe(true);
|
||||
expect(result.hostErrors).toEqual([
|
||||
{ serverId: "local", serverName: "Local", message: "unavailable" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import {
|
||||
useSessionStore,
|
||||
type EmptyProjectDescriptor,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||
@@ -24,7 +24,7 @@ export interface ProjectHostReplica {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
emptyProjects: EmptyProjectDescriptor[];
|
||||
projects: ProjectDescriptor[];
|
||||
}
|
||||
|
||||
export interface ProjectHostRuntimeState {
|
||||
@@ -87,7 +87,7 @@ function selectProjectHostReplicas(
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
workspaces: Array.from(session?.workspaces.values() ?? []),
|
||||
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
|
||||
projects: Array.from(session?.projects.values() ?? []),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export function deriveProjectsFromReplica(input: {
|
||||
serverName: replica.serverName,
|
||||
isOnline: runtimeState?.isOnline ?? false,
|
||||
workspaces: replica.workspaces,
|
||||
emptyProjects: replica.emptyProjects,
|
||||
projects: replica.projects,
|
||||
};
|
||||
});
|
||||
const hostErrors = input.replicas.flatMap((replica) => {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
computeSidebarOrderUpdates,
|
||||
@@ -128,35 +125,12 @@ export function useSidebarWorkspacesList(options?: {
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
if (snapshot?.connectionStatus !== "online") continue;
|
||||
const client = runtime.getClient(serverId);
|
||||
if (!client) continue;
|
||||
void (async () => {
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
try {
|
||||
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
|
||||
client,
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
});
|
||||
for (const workspace of workspaces) {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
next.set(workspace.id, workspace);
|
||||
}
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(serverId, next);
|
||||
// Keep parents with no workspaces yet, so a manual refresh doesn't drop
|
||||
// a freshly-added project from the sidebar.
|
||||
store.setEmptyProjects(serverId, emptyProjects);
|
||||
store.setHasHydratedWorkspaces(serverId, true);
|
||||
} catch (error) {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
error,
|
||||
});
|
||||
// ignore explicit refresh failures; hook keeps existing data
|
||||
}
|
||||
})();
|
||||
void runtime.refreshDirectories(serverId).catch((error) => {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [isActive, runtime, serverIds]);
|
||||
|
||||
|
||||
@@ -4,14 +4,7 @@ import type {
|
||||
WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
|
||||
export interface HostProjectListItem {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
hosts: WorkspaceStructureHostPlacement[];
|
||||
workspaceKeys: string[];
|
||||
}
|
||||
export type HostProjectListItem = WorkspaceStructureProject;
|
||||
|
||||
export interface HostProjectRouteContext {
|
||||
serverId: string;
|
||||
@@ -31,33 +24,21 @@ export function canCreateWorktreeForProjectKind(
|
||||
return projectKind === "git";
|
||||
}
|
||||
|
||||
export function buildHostProjectList(input: {
|
||||
projects: readonly WorkspaceStructureProject[];
|
||||
}): HostProjectListItem[] {
|
||||
return input.projects.map((project) => ({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectKind: project.projectKind,
|
||||
iconWorkingDir: project.iconWorkingDir,
|
||||
hosts: project.hosts,
|
||||
workspaceKeys: project.workspaceKeys,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
|
||||
const projectKey = trimOptional(route.projectId);
|
||||
const projectId = route.projectId?.trim() || undefined;
|
||||
const iconWorkingDir = trimOptional(route.sourceDirectory);
|
||||
if (!projectKey || !iconWorkingDir) {
|
||||
if (!projectId || !iconWorkingDir) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
projectKey,
|
||||
projectName: trimOptional(route.displayName) ?? projectKey,
|
||||
projectKey: projectId,
|
||||
projectName: trimOptional(route.displayName) || projectId,
|
||||
projectKind: "git",
|
||||
iconWorkingDir,
|
||||
hosts: [
|
||||
{
|
||||
serverId: route.serverId,
|
||||
projectId,
|
||||
iconWorkingDir,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
@@ -73,20 +54,24 @@ export function hostProjectFromWorkspace(input: {
|
||||
if (!input.workspace) {
|
||||
return null;
|
||||
}
|
||||
const projectKey = input.workspace.projectId.trim();
|
||||
const projectId = input.workspace.projectId.trim() || undefined;
|
||||
if (!projectId) {
|
||||
return null;
|
||||
}
|
||||
const iconWorkingDir = input.workspace.projectRootPath.trim();
|
||||
if (!projectKey || !iconWorkingDir) {
|
||||
if (!iconWorkingDir) {
|
||||
return null;
|
||||
}
|
||||
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
|
||||
return {
|
||||
projectKey,
|
||||
projectName: input.workspace.projectDisplayName || projectKey,
|
||||
projectKey: projectId,
|
||||
projectName: input.workspace.projectDisplayName || projectId,
|
||||
projectKind: input.workspace.projectKind,
|
||||
iconWorkingDir,
|
||||
hosts: [
|
||||
{
|
||||
serverId: input.serverId,
|
||||
projectId: input.workspace.projectId,
|
||||
iconWorkingDir,
|
||||
canCreateWorktree: canCreate,
|
||||
},
|
||||
@@ -99,11 +84,25 @@ function projectCanCreateWorktree(project: HostProjectListItem): boolean {
|
||||
return project.hosts.some((h) => h.canCreateWorktree);
|
||||
}
|
||||
|
||||
function getHostProjectPlacement(
|
||||
project: HostProjectListItem,
|
||||
serverId: string,
|
||||
): WorkspaceStructureHostPlacement | null {
|
||||
for (const host of project.hosts) {
|
||||
if (host.serverId === serverId) return host;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getHostProjectSourceDirectory(
|
||||
project: HostProjectListItem,
|
||||
serverId: string,
|
||||
): string | null {
|
||||
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
|
||||
return getHostProjectPlacement(project, serverId)?.iconWorkingDir ?? null;
|
||||
}
|
||||
|
||||
export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null {
|
||||
return getHostProjectPlacement(project, serverId)?.projectId ?? null;
|
||||
}
|
||||
|
||||
export function canCreateWorkspaceForHostProject(input: {
|
||||
@@ -111,7 +110,7 @@ export function canCreateWorkspaceForHostProject(input: {
|
||||
serverId: string;
|
||||
allowAllProjects: boolean;
|
||||
}): boolean {
|
||||
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
|
||||
const host = getHostProjectPlacement(input.project, input.serverId);
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
@@ -144,8 +143,20 @@ export function resolveInitialWorkspaceProject(input: {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
const candidatePlacement = candidate.hosts.find(
|
||||
(host) => host.serverId === input.serverId && host.projectId,
|
||||
);
|
||||
const hydratedProject =
|
||||
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
|
||||
(candidatePlacement
|
||||
? input.projects.find((project) =>
|
||||
project.hosts.some(
|
||||
(host) =>
|
||||
host.serverId === input.serverId && host.projectId === candidatePlacement.projectId,
|
||||
),
|
||||
)
|
||||
: undefined) ??
|
||||
input.projects.find((project) => project.projectKey === candidate.projectKey) ??
|
||||
candidate;
|
||||
if (
|
||||
canCreateWorkspaceForHostProject({
|
||||
project: hydratedProject,
|
||||
|
||||
@@ -1,312 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { HostProjectListItem } from "./host-project-model";
|
||||
import {
|
||||
buildHostProjectList,
|
||||
canCreateWorkspaceForHostProject,
|
||||
canCreateWorktreeForProjectKind,
|
||||
filterWorkspaceProjectsForHost,
|
||||
getHostProjectId,
|
||||
getHostProjectSourceDirectory,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
resolveInitialWorkspaceProject,
|
||||
resolveInitialWorktreeProject,
|
||||
resolveSelectedHostProject,
|
||||
type HostProjectListItem,
|
||||
} from "./host-project-model";
|
||||
|
||||
function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceStructureProject {
|
||||
function project(): HostProjectListItem {
|
||||
return {
|
||||
projectKey: input.projectKey ?? "project-a",
|
||||
projectName: input.projectName ?? "Project A",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
|
||||
hosts: input.hosts ?? [
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
|
||||
canCreateWorktree: input.projectKind !== "directory",
|
||||
projectId: "prj_a",
|
||||
iconWorkingDir: "/repo/a",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
|
||||
};
|
||||
}
|
||||
|
||||
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
|
||||
return {
|
||||
projectKey: input.projectKey ?? "project-a",
|
||||
projectName: input.projectName ?? "Project A",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
|
||||
hosts: input.hosts ?? [
|
||||
{
|
||||
serverId: "host-a",
|
||||
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
|
||||
serverId: "host-b",
|
||||
projectId: "prj_b",
|
||||
iconWorkingDir: "/repo/b",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
|
||||
workspaceKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id ?? "workspace-a",
|
||||
projectId: input.projectId ?? "project-a",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project A",
|
||||
projectRootPath: input.projectRootPath ?? "/repo/a",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo/a",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const routeProject = hostProject({
|
||||
projectKey: "route-project",
|
||||
projectName: "Route Project",
|
||||
iconWorkingDir: "/repo/route",
|
||||
});
|
||||
const lastActiveProject = hostProject({
|
||||
projectKey: "last-project",
|
||||
projectName: "Last Project",
|
||||
iconWorkingDir: "/repo/last",
|
||||
});
|
||||
const firstProject = hostProject({
|
||||
projectKey: "first-project",
|
||||
projectName: "First Project",
|
||||
iconWorkingDir: "/repo/first",
|
||||
});
|
||||
|
||||
describe("host project list", () => {
|
||||
it("preserves workspace-structure order and project metadata", () => {
|
||||
expect(
|
||||
buildHostProjectList({
|
||||
projects: [
|
||||
structureProject({
|
||||
projectKey: "project-b",
|
||||
projectName: "Project B",
|
||||
projectKind: "directory",
|
||||
iconWorkingDir: "/repo/b",
|
||||
workspaceKeys: ["workspace-b"],
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
|
||||
}),
|
||||
structureProject({
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
workspaceKeys: ["workspace-a"],
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
projectKey: "project-b",
|
||||
projectName: "Project B",
|
||||
projectKind: "directory",
|
||||
iconWorkingDir: "/repo/b",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
|
||||
workspaceKeys: ["workspace-b"],
|
||||
},
|
||||
{
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
||||
workspaceKeys: ["workspace-a"],
|
||||
},
|
||||
]);
|
||||
describe("host project lookups", () => {
|
||||
test("returns host-local ids and roots without falling back to the grouping key", () => {
|
||||
expect(getHostProjectId(project(), "host-b")).toBe("prj_b");
|
||||
expect(getHostProjectSourceDirectory(project(), "host-b")).toBe("/repo/b");
|
||||
expect(getHostProjectId(project(), "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps worktree capability separate from project listability", () => {
|
||||
expect(canCreateWorktreeForProjectKind("git")).toBe(true);
|
||||
expect(canCreateWorktreeForProjectKind("directory")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses route project before last active project when it can create worktrees", () => {
|
||||
test("checks workspace creation against the selected host placement", () => {
|
||||
expect(
|
||||
resolveInitialWorktreeProject({
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
projects: [firstProject],
|
||||
}),
|
||||
).toEqual(routeProject);
|
||||
});
|
||||
|
||||
it("skips non-worktree route and last-active projects", () => {
|
||||
expect(
|
||||
resolveInitialWorktreeProject({
|
||||
routeProject: {
|
||||
...routeProject,
|
||||
projectKind: "directory",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/route", canCreateWorktree: false }],
|
||||
},
|
||||
lastActiveProject: {
|
||||
...lastActiveProject,
|
||||
projectKind: "directory",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/last", canCreateWorktree: false }],
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
...firstProject,
|
||||
projectKind: "directory",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
|
||||
],
|
||||
},
|
||||
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
|
||||
],
|
||||
}),
|
||||
).toMatchObject({ projectKey: "git-project" });
|
||||
});
|
||||
|
||||
it("leaves the project empty when no worktree-capable project is available", () => {
|
||||
expect(
|
||||
resolveInitialWorktreeProject({
|
||||
routeProject: null,
|
||||
lastActiveProject: null,
|
||||
projects: [
|
||||
{
|
||||
...firstProject,
|
||||
projectKind: "directory",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("filters new-workspace projects to the selected host", () => {
|
||||
const hostAOnly = hostProject({
|
||||
projectKey: "host-a-project",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
||||
});
|
||||
const hostBOnly = hostProject({
|
||||
projectKey: "host-b-project",
|
||||
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
|
||||
});
|
||||
|
||||
expect(
|
||||
filterWorkspaceProjectsForHost({
|
||||
projects: [hostAOnly, hostBOnly],
|
||||
canCreateWorkspaceForHostProject({
|
||||
project: project(),
|
||||
serverId: "host-b",
|
||||
allowAllProjects: false,
|
||||
}).map((project) => project.projectKey),
|
||||
).toEqual(["host-b-project"]);
|
||||
});
|
||||
|
||||
it("allows directory projects only when workspace multiplicity is supported", () => {
|
||||
const directoryProject = hostProject({
|
||||
projectKey: "directory-project",
|
||||
projectKind: "directory",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/directory", canCreateWorktree: false }],
|
||||
});
|
||||
|
||||
expect(
|
||||
canCreateWorkspaceForHostProject({
|
||||
project: directoryProject,
|
||||
serverId: "host-a",
|
||||
allowAllProjects: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canCreateWorkspaceForHostProject({
|
||||
project: directoryProject,
|
||||
serverId: "host-a",
|
||||
allowAllProjects: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back when the route project is not available on the selected host", () => {
|
||||
const selectedHostProject = hostProject({
|
||||
projectKey: "selected-host-project",
|
||||
hosts: [
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/selected-host", canCreateWorktree: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveInitialWorkspaceProject({
|
||||
routeProject,
|
||||
lastActiveProject: null,
|
||||
projects: [selectedHostProject],
|
||||
serverId: "host-b",
|
||||
allowAllProjects: false,
|
||||
}),
|
||||
).toEqual(selectedHostProject);
|
||||
});
|
||||
|
||||
it("resolves the selected host project source directory", () => {
|
||||
const project = hostProject({
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(getHostProjectSourceDirectory(project, "host-b")).toBe("/repo/b");
|
||||
expect(getHostProjectSourceDirectory(project, "host-c")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a selected route project available before project hydration", () => {
|
||||
expect(
|
||||
resolveSelectedHostProject({
|
||||
selectedProjectKey: routeProject.projectKey,
|
||||
projects: [],
|
||||
routeProject,
|
||||
lastActiveProject: null,
|
||||
}),
|
||||
).toEqual(routeProject);
|
||||
});
|
||||
|
||||
it("converts route project only when it has a key and source directory", () => {
|
||||
test("builds an unhydrated route project around the routed project id", () => {
|
||||
expect(
|
||||
hostProjectFromRoute({
|
||||
serverId: "host-a",
|
||||
projectId: "project-a",
|
||||
displayName: "Project A",
|
||||
projectId: "prj_a",
|
||||
displayName: "App",
|
||||
sourceDirectory: "/repo/a",
|
||||
}),
|
||||
).toEqual({
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
||||
workspaceKeys: [],
|
||||
});
|
||||
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
|
||||
});
|
||||
|
||||
it("converts last active workspaces with matching worktree capability", () => {
|
||||
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
||||
workspaceKeys: ["host-a:workspace-a"],
|
||||
});
|
||||
|
||||
expect(
|
||||
hostProjectFromWorkspace({
|
||||
serverId: "host-a",
|
||||
workspace: workspace({ projectKind: "directory" }),
|
||||
}),
|
||||
).toMatchObject({
|
||||
projectKind: "directory",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }],
|
||||
projectKey: "prj_a",
|
||||
hosts: [{ serverId: "host-a", projectId: "prj_a" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
|
||||
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
|
||||
import { type HostProjectListItem } from "@/projects/host-project-model";
|
||||
|
||||
export {
|
||||
buildHostProjectList,
|
||||
canCreateWorkspaceForHostProject,
|
||||
canCreateWorktreeForProjectKind,
|
||||
filterWorkspaceProjectsForHost,
|
||||
getHostProjectSourceDirectory,
|
||||
getHostProjectId,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
resolveInitialWorkspaceProject,
|
||||
@@ -19,10 +18,5 @@ export {
|
||||
|
||||
export function useHostProjects(serverIds: string[]): HostProjectListItem[] {
|
||||
const workspaceStructure = useWorkspaceStructure(serverIds);
|
||||
return useMemo(() => {
|
||||
if (workspaceStructure.projects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return buildHostProjectList({ projects: workspaceStructure.projects });
|
||||
}, [workspaceStructure.projects]);
|
||||
return workspaceStructure.projects;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
|
||||
const project: ProjectRemoveProject = {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
hosts: [{ serverId: "host-a" }, { serverId: "host-b" }],
|
||||
hosts: [
|
||||
{ serverId: "host-a", projectId: "prj_host_a" },
|
||||
{ serverId: "host-b", projectId: "prj_host_b" },
|
||||
],
|
||||
};
|
||||
|
||||
function createProjectRemoveClient() {
|
||||
@@ -46,12 +49,14 @@ describe("project remove policy", () => {
|
||||
|
||||
expect(readiness).toEqual({
|
||||
kind: "ready",
|
||||
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
|
||||
targets: [
|
||||
{ serverId: "host-a", projectId: "prj_host_a" },
|
||||
{ serverId: "host-b", projectId: "prj_host_b" },
|
||||
],
|
||||
});
|
||||
|
||||
const outcome = await removeProjectFromHosts({
|
||||
projectKey: project.projectKey,
|
||||
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
|
||||
targets: readiness.kind === "ready" ? readiness.targets : [],
|
||||
getClient: (serverId) => {
|
||||
if (serverId === "host-a") return hostA.client;
|
||||
if (serverId === "host-b") return hostB.client;
|
||||
@@ -60,16 +65,18 @@ describe("project remove policy", () => {
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
|
||||
expect(hostA.removedProjectKeys).toEqual([project.projectKey]);
|
||||
expect(hostB.removedProjectKeys).toEqual([project.projectKey]);
|
||||
expect(hostA.removedProjectKeys).toEqual(["prj_host_a"]);
|
||||
expect(hostB.removedProjectKeys).toEqual(["prj_host_b"]);
|
||||
});
|
||||
|
||||
it("reports disconnected hosts before sending any remove request", async () => {
|
||||
const hostA = createProjectRemoveClient();
|
||||
|
||||
const outcome = await removeProjectFromHosts({
|
||||
projectKey: project.projectKey,
|
||||
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
|
||||
targets: [
|
||||
{ serverId: "host-a", projectId: "prj_host_a" },
|
||||
{ serverId: "host-b", projectId: "prj_host_b" },
|
||||
],
|
||||
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { selectHostFeature } from "@/runtime/host-features";
|
||||
|
||||
interface ProjectRemoveHost {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface ProjectRemoveProject {
|
||||
@@ -13,6 +14,7 @@ export interface ProjectRemoveProject {
|
||||
|
||||
export interface ProjectRemoveTarget {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export type ProjectRemoveReadiness =
|
||||
@@ -38,7 +40,10 @@ export function getProjectRemoveReadiness(input: {
|
||||
unsupportedServerIds.push(host.serverId);
|
||||
continue;
|
||||
}
|
||||
targets.push({ serverId: host.serverId });
|
||||
targets.push({
|
||||
serverId: host.serverId,
|
||||
projectId: host.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
if (unsupportedServerIds.length > 0) {
|
||||
@@ -59,11 +64,10 @@ export function getCurrentProjectRemoveReadiness(
|
||||
}
|
||||
|
||||
export async function removeProjectFromHosts(input: {
|
||||
projectKey: string;
|
||||
targets: readonly ProjectRemoveTarget[];
|
||||
getClient: (serverId: string) => ProjectRemoveClient | null;
|
||||
}): Promise<ProjectRemoveOutcome> {
|
||||
const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = [];
|
||||
const clients: Array<{ serverId: string; projectId: string; client: ProjectRemoveClient }> = [];
|
||||
const disconnectedServerIds: string[] = [];
|
||||
|
||||
for (const target of input.targets) {
|
||||
@@ -72,7 +76,7 @@ export async function removeProjectFromHosts(input: {
|
||||
disconnectedServerIds.push(target.serverId);
|
||||
continue;
|
||||
}
|
||||
clients.push({ serverId: target.serverId, client });
|
||||
clients.push({ serverId: target.serverId, projectId: target.projectId, client });
|
||||
}
|
||||
|
||||
if (disconnectedServerIds.length > 0) {
|
||||
@@ -80,8 +84,8 @@ export async function removeProjectFromHosts(input: {
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
clients.map(async ({ client }) => {
|
||||
await client.removeProject(input.projectKey);
|
||||
clients.map(async ({ client, projectId }) => {
|
||||
await client.removeProject(projectId);
|
||||
}),
|
||||
);
|
||||
const failedServerIds: string[] = [];
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
type EmptyProjectDescriptor,
|
||||
normalizeEmptyProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
|
||||
export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">;
|
||||
export type FetchWorkspacesSort = NonNullable<
|
||||
Parameters<DaemonClient["fetchWorkspaces"]>[0]
|
||||
>["sort"];
|
||||
|
||||
export interface FetchWorkspaceDescriptorsResult {
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
/**
|
||||
* Project parents with no active workspaces. The daemon only rides these on
|
||||
* the first page of `fetchWorkspaces`, so a freshly-added project that has no
|
||||
* workspace yet shows up here and nowhere else — drop it and the project is
|
||||
* invisible to anything that derives projects from workspaces alone.
|
||||
*/
|
||||
emptyProjects: EmptyProjectDescriptor[];
|
||||
}
|
||||
|
||||
export async function fetchAllWorkspaceDescriptors(input: {
|
||||
client: FetchWorkspacesClient;
|
||||
sort: FetchWorkspacesSort;
|
||||
}): Promise<FetchWorkspaceDescriptorsResult> {
|
||||
const workspaces: WorkspaceDescriptor[] = [];
|
||||
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
|
||||
let cursor: string | null = null;
|
||||
|
||||
while (true) {
|
||||
const payload = await input.client.fetchWorkspaces({
|
||||
sort: input.sort,
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
workspaces.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
|
||||
for (const project of payload.emptyProjects ?? []) {
|
||||
const descriptor = normalizeEmptyProjectDescriptor(project);
|
||||
emptyProjects.set(descriptor.projectId, descriptor);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = payload.pageInfo.nextCursor;
|
||||
}
|
||||
|
||||
return { workspaces, emptyProjects: Array.from(emptyProjects.values()) };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildWorkspaceStructureProjects } from "./workspace-structure";
|
||||
|
||||
function project(input: {
|
||||
id: string;
|
||||
key: string | null;
|
||||
root: string;
|
||||
name?: string;
|
||||
}): ProjectDescriptor {
|
||||
return {
|
||||
projectId: input.id,
|
||||
projectKey: input.key,
|
||||
projectDisplayName: input.name ?? "acme/app",
|
||||
projectCustomName: null,
|
||||
projectRootPath: input.root,
|
||||
projectKind: "git",
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
|
||||
return {
|
||||
id,
|
||||
projectId,
|
||||
projectDisplayName: "acme/app",
|
||||
projectCustomName: null,
|
||||
projectRootPath: root,
|
||||
workspaceDirectory: root,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildWorkspaceStructureProjects", () => {
|
||||
test("groups the same project key across hosts and keeps host-local ids", () => {
|
||||
const key = "remote:github.com/acme/app";
|
||||
const result = buildWorkspaceStructureProjects({
|
||||
sessions: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
projects: [project({ id: "prj_a", key, root: "/a/app" })],
|
||||
workspaces: [workspace("ws-a", "prj_a", "/a/app")],
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
projects: [project({ id: "prj_b", key, root: "/b/app" })],
|
||||
workspaces: [workspace("ws-b", "prj_b", "/b/app")],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
projectKey: key,
|
||||
hosts: [
|
||||
{ serverId: "host-a", projectId: "prj_a" },
|
||||
{ serverId: "host-b", projectId: "prj_b" },
|
||||
],
|
||||
workspaceKeys: ["host-a:ws-a", "host-b:ws-b"],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps two clones with the same key on one host separate", () => {
|
||||
const key = "remote:github.com/acme/app";
|
||||
const result = buildWorkspaceStructureProjects({
|
||||
sessions: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
projects: [
|
||||
project({ id: "prj_one", key, root: "/repos/one" }),
|
||||
project({ id: "prj_two", key, root: "/repos/two" }),
|
||||
],
|
||||
workspaces: [
|
||||
workspace("ws-one", "prj_one", "/repos/one"),
|
||||
workspace("ws-two", "prj_two", "/repos/two"),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((item) => item.hosts[0]?.projectId).sort()).toEqual(["prj_one", "prj_two"]);
|
||||
expect(result.map((item) => item.workspaceKeys[0]).sort()).toEqual([
|
||||
"host-a:ws-one",
|
||||
"host-a:ws-two",
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not let project order choose which same-host clone groups with another host", () => {
|
||||
const key = "remote:github.com/acme/app";
|
||||
const hostAProjects = [
|
||||
project({ id: "prj_one", key, root: "/repos/one" }),
|
||||
project({ id: "prj_two", key, root: "/repos/two" }),
|
||||
];
|
||||
const build = (projects: ProjectDescriptor[]) =>
|
||||
buildWorkspaceStructureProjects({
|
||||
sessions: [
|
||||
{ serverId: "host-a", projects, workspaces: [] },
|
||||
{
|
||||
serverId: "host-b",
|
||||
projects: [project({ id: "prj_remote", key, root: "/repos/remote" })],
|
||||
workspaces: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const identitiesByProjectId = (projects: ReturnType<typeof build>) => {
|
||||
const identities: Record<string, string> = {};
|
||||
for (const group of projects) {
|
||||
for (const host of group.hosts) identities[host.projectId] = group.projectKey;
|
||||
}
|
||||
return identities;
|
||||
};
|
||||
|
||||
const forward = build(hostAProjects);
|
||||
const reversed = build(hostAProjects.toReversed());
|
||||
|
||||
expect(forward).toHaveLength(3);
|
||||
expect(identitiesByProjectId(forward)).toEqual(identitiesByProjectId(reversed));
|
||||
expect(new Set(Object.values(identitiesByProjectId(forward))).size).toBe(3);
|
||||
});
|
||||
|
||||
test("keeps projects without persisted keys scoped to their host", () => {
|
||||
const result = buildWorkspaceStructureProjects({
|
||||
sessions: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })],
|
||||
workspaces: [],
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })],
|
||||
workspaces: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(new Set(result.map((item) => item.projectKey)).size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
|
||||
export interface WorkspaceStructureHostPlacement {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
iconWorkingDir: string;
|
||||
canCreateWorktree: boolean;
|
||||
}
|
||||
@@ -20,126 +21,55 @@ export interface WorkspaceStructure {
|
||||
projects: WorkspaceStructureProject[];
|
||||
}
|
||||
|
||||
function compareWorkspaceStructureItems(
|
||||
left: { workspaceId: string; workspaceName: string },
|
||||
right: { workspaceId: string; workspaceName: string },
|
||||
): number {
|
||||
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (nameDelta !== 0) {
|
||||
return nameDelta;
|
||||
}
|
||||
|
||||
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
function compareWorkspaceStructureProjects(
|
||||
left: WorkspaceStructureProject,
|
||||
right: WorkspaceStructureProject,
|
||||
): number {
|
||||
return left.projectName.localeCompare(right.projectName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
function canCreateWorktreeForProjectKind(projectKind: WorkspaceDescriptor["projectKind"]): boolean {
|
||||
return projectKind === "git";
|
||||
}
|
||||
|
||||
interface WorkspaceStructureSession {
|
||||
serverId: string;
|
||||
projects: Iterable<ProjectDescriptor>;
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
emptyProjects?: Iterable<EmptyProjectDescriptor>;
|
||||
}
|
||||
|
||||
interface ProjectDraft {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
hasCustomName: boolean;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
hosts: Map<string, WorkspaceStructureHostPlacement>;
|
||||
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
||||
}
|
||||
|
||||
/** The single app boundary that turns host-local projects into grouped display projects. */
|
||||
export function buildWorkspaceStructureProjects(input: {
|
||||
sessions: WorkspaceStructureSession[];
|
||||
}): WorkspaceStructureProject[] {
|
||||
const byProject = new Map<
|
||||
string,
|
||||
{
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
hosts: Map<string, WorkspaceStructureHostPlacement>;
|
||||
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
||||
}
|
||||
>();
|
||||
const byProject = new Map<string, ProjectDraft>();
|
||||
const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = [];
|
||||
const keyCountsByServer = new Map<string, Map<string, number>>();
|
||||
const viewKeyByServerProjectId = new Map<string, Map<string, string>>();
|
||||
|
||||
for (const session of input.sessions) {
|
||||
for (const emptyProject of session.emptyProjects ?? []) {
|
||||
const projectKey = emptyProject.projectId;
|
||||
const placement = {
|
||||
serverId: session.serverId,
|
||||
iconWorkingDir: emptyProject.projectRootPath,
|
||||
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
|
||||
};
|
||||
const existing = byProject.get(projectKey);
|
||||
|
||||
if (!existing) {
|
||||
byProject.set(projectKey, {
|
||||
projectKey,
|
||||
projectName:
|
||||
emptyProject.projectCustomName ??
|
||||
emptyProject.projectDisplayName ??
|
||||
projectDisplayNameFromProjectId(projectKey),
|
||||
projectKind: emptyProject.projectKind,
|
||||
iconWorkingDir: emptyProject.projectRootPath,
|
||||
hosts: new Map([[session.serverId, placement]]),
|
||||
workspaces: [],
|
||||
});
|
||||
continue;
|
||||
for (const project of session.projects) {
|
||||
projectEntries.push({ serverId: session.serverId, project });
|
||||
const sharedKey = project.projectKey ?? null;
|
||||
if (sharedKey) {
|
||||
const counts = getOrCreate(keyCountsByServer, session.serverId, () => new Map());
|
||||
counts.set(sharedKey, (counts.get(sharedKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
existing.hosts.set(session.serverId, placement);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { serverId, project } of projectEntries) {
|
||||
const projectKey = addProjectToView({ byProject, keyCountsByServer, serverId, project });
|
||||
getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set(
|
||||
project.projectId,
|
||||
projectKey,
|
||||
);
|
||||
}
|
||||
|
||||
for (const session of input.sessions) {
|
||||
for (const workspace of session.workspaces) {
|
||||
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
|
||||
const existing = byProject.get(projectKey);
|
||||
|
||||
if (!existing) {
|
||||
byProject.set(projectKey, {
|
||||
projectKey,
|
||||
projectName:
|
||||
workspace.projectCustomName ??
|
||||
workspace.projectDisplayName ??
|
||||
projectDisplayNameFromProjectId(projectKey),
|
||||
projectKind: workspace.projectKind,
|
||||
iconWorkingDir: workspace.projectRootPath,
|
||||
hosts: new Map([
|
||||
[
|
||||
session.serverId,
|
||||
{
|
||||
serverId: session.serverId,
|
||||
iconWorkingDir: workspace.projectRootPath,
|
||||
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
|
||||
},
|
||||
],
|
||||
]),
|
||||
workspaces: [
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
workspaceKey: `${session.serverId}:${workspace.id}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.hosts.set(session.serverId, {
|
||||
serverId: session.serverId,
|
||||
iconWorkingDir: workspace.projectRootPath,
|
||||
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
|
||||
});
|
||||
existing.workspaces.push({
|
||||
const projectKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId);
|
||||
if (!projectKey) continue;
|
||||
byProject.get(projectKey)?.workspaces.push({
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
workspaceKey: `${session.serverId}:${workspace.id}`,
|
||||
@@ -147,19 +77,82 @@ export function buildWorkspaceStructureProjects(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const projects: WorkspaceStructureProject[] = [];
|
||||
for (const raw of byProject.values()) {
|
||||
const sortedWorkspaces = [...raw.workspaces].sort(compareWorkspaceStructureItems);
|
||||
projects.push({
|
||||
projectKey: raw.projectKey,
|
||||
projectName: raw.projectName,
|
||||
projectKind: raw.projectKind,
|
||||
iconWorkingDir: raw.iconWorkingDir,
|
||||
hosts: Array.from(raw.hosts.values()),
|
||||
workspaceKeys: sortedWorkspaces.map((w) => w.workspaceKey),
|
||||
return Array.from(byProject.values())
|
||||
.map((draft) => ({
|
||||
projectKey: draft.projectKey,
|
||||
projectName: draft.projectName,
|
||||
projectKind: draft.projectKind,
|
||||
iconWorkingDir: draft.iconWorkingDir,
|
||||
hosts: Array.from(draft.hosts.values()),
|
||||
workspaceKeys: draft.workspaces
|
||||
.sort(compareWorkspaceStructureItems)
|
||||
.map((workspace) => workspace.workspaceKey),
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
left.projectName.localeCompare(right.projectName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function addProjectToView(input: {
|
||||
byProject: Map<string, ProjectDraft>;
|
||||
keyCountsByServer: Map<string, Map<string, number>>;
|
||||
serverId: string;
|
||||
project: ProjectDescriptor;
|
||||
}): string {
|
||||
const { byProject, keyCountsByServer, serverId, project } = input;
|
||||
const sharedKey = project.projectKey ?? null;
|
||||
const canUseSharedKey =
|
||||
sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1;
|
||||
const projectKey = canUseSharedKey ? sharedKey : JSON.stringify([serverId, project.projectId]);
|
||||
const placement: WorkspaceStructureHostPlacement = {
|
||||
serverId,
|
||||
projectId: project.projectId,
|
||||
iconWorkingDir: project.projectRootPath,
|
||||
canCreateWorktree: project.projectKind === "git",
|
||||
};
|
||||
const draft = byProject.get(projectKey);
|
||||
if (!draft) {
|
||||
byProject.set(projectKey, {
|
||||
projectKey,
|
||||
projectName:
|
||||
project.projectCustomName ??
|
||||
project.projectDisplayName ??
|
||||
projectDisplayNameFromProjectId(project.projectId),
|
||||
hasCustomName: Boolean(project.projectCustomName),
|
||||
projectKind: project.projectKind,
|
||||
iconWorkingDir: project.projectRootPath,
|
||||
hosts: new Map([[serverId, placement]]),
|
||||
workspaces: [],
|
||||
});
|
||||
} else {
|
||||
if (project.projectCustomName && !draft.hasCustomName) {
|
||||
draft.projectName = project.projectCustomName;
|
||||
draft.hasCustomName = true;
|
||||
}
|
||||
draft.hosts.set(serverId, placement);
|
||||
}
|
||||
return projectKey;
|
||||
}
|
||||
|
||||
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
|
||||
const existing = map.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = create();
|
||||
map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
projects.sort(compareWorkspaceStructureProjects);
|
||||
return projects;
|
||||
function compareWorkspaceStructureItems(
|
||||
left: { workspaceId: string; workspaceName: string },
|
||||
right: { workspaceId: string; workspaceName: string },
|
||||
): number {
|
||||
return (
|
||||
left.workspaceName.localeCompare(right.workspaceName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
}) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
|
||||
|
||||
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
||||
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
fetchAgentsCalls = 0;
|
||||
fetchWorkspacesCalls = 0;
|
||||
listProjectsCalls = 0;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private readonly handlers = new Map<
|
||||
SessionOutboundMessage["type"],
|
||||
@@ -63,6 +65,22 @@ class FakeDirectoryClient {
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
};
|
||||
}
|
||||
|
||||
async listProjects(): Promise<ProjectListResult> {
|
||||
this.listProjectsCalls += 1;
|
||||
return {
|
||||
requestId: "projects",
|
||||
projects: [
|
||||
{
|
||||
projectId: "project-1",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectDisplayName: "acme/app",
|
||||
projectRootPath: "/repo/app",
|
||||
projectKind: "git",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const serverIds = new Set<string>();
|
||||
@@ -119,6 +137,28 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("fetches the project descriptor channel when the daemon advertises it", async () => {
|
||||
const serverId = "project-list";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, projectList: true },
|
||||
});
|
||||
|
||||
await directory.refreshWorkspaces();
|
||||
|
||||
expect(client.listProjectsCalls).toBe(1);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.projects.get("project-1")).toMatchObject({
|
||||
projectId: "project-1",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
|
||||
const serverId = "session-wait-reconnect";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
@@ -200,17 +240,14 @@ describe("DirectorySync session readiness", () => {
|
||||
});
|
||||
await refresh;
|
||||
|
||||
const emptyProjects = useSessionStore.getState().sessions[serverId]?.emptyProjects;
|
||||
expect(Array.from(emptyProjects?.keys() ?? [])).toEqual([
|
||||
"snapshot-project",
|
||||
"workspace-project",
|
||||
]);
|
||||
expect(emptyProjects?.get("snapshot-project")).toMatchObject({
|
||||
const projects = useSessionStore.getState().sessions[serverId]?.projects;
|
||||
expect(Array.from(projects?.keys() ?? [])).toEqual(["snapshot-project", "workspace-project"]);
|
||||
expect(projects?.get("snapshot-project")).toMatchObject({
|
||||
projectDisplayName: "Renamed during hydration",
|
||||
projectRootPath: "/moved/snapshot-project",
|
||||
projectKind: "directory",
|
||||
});
|
||||
expect(emptyProjects?.get("workspace-project")).toMatchObject({
|
||||
expect(projects?.get("workspace-project")).toMatchObject({
|
||||
projectDisplayName: "Project from workspace update",
|
||||
});
|
||||
directory.dispose();
|
||||
@@ -247,7 +284,7 @@ describe("DirectorySync session readiness", () => {
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.emptyProjects.get("early-project"),
|
||||
useSessionStore.getState().sessions[serverId]?.projects.get("early-project"),
|
||||
).toMatchObject({
|
||||
projectDisplayName: "Early project",
|
||||
projectRootPath: "/repo/early-project",
|
||||
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor,
|
||||
normalizeProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import {
|
||||
readLegacyDaemonWorkspaceDirectory,
|
||||
@@ -109,7 +111,7 @@ export class DirectorySync {
|
||||
const source = connection.source;
|
||||
this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
projects: new Map(),
|
||||
}));
|
||||
const subscriptions = [
|
||||
client.on("agent_update", (message) => {
|
||||
@@ -205,8 +207,12 @@ export class DirectorySync {
|
||||
}
|
||||
if (completion.snapshot.legacy) {
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(this.serverId, buildLegacyWorkspaces(completion.snapshot.entries));
|
||||
store.setEmptyProjects(this.serverId, []);
|
||||
const workspaces = buildLegacyWorkspaces(completion.snapshot.entries);
|
||||
store.setWorkspaces(this.serverId, workspaces);
|
||||
store.setProjects(
|
||||
this.serverId,
|
||||
Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace),
|
||||
);
|
||||
store.setHasHydratedWorkspaces(this.serverId, true);
|
||||
}
|
||||
const deltas = completion.snapshot.legacy
|
||||
@@ -233,7 +239,7 @@ export class DirectorySync {
|
||||
const { client, source } = this.requireOnline();
|
||||
const transaction = this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
projects: new Map(),
|
||||
}));
|
||||
try {
|
||||
await this.waitForSessionMetadata(client, source);
|
||||
@@ -243,7 +249,17 @@ export class DirectorySync {
|
||||
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
|
||||
return;
|
||||
}
|
||||
const supportsProjectList = serverInfo.features?.projectList === true;
|
||||
if (supportsProjectList) {
|
||||
const payload = await client.listProjects();
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
for (const entry of payload.projects) {
|
||||
const project = normalizeProjectDescriptor(entry);
|
||||
transaction.snapshot.projects.set(project.projectId, project);
|
||||
}
|
||||
}
|
||||
await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true);
|
||||
if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot);
|
||||
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
||||
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
|
||||
}
|
||||
@@ -279,8 +295,8 @@ export class DirectorySync {
|
||||
transaction.snapshot.workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
for (const entry of payload.emptyProjects ?? []) {
|
||||
const project = normalizeEmptyProjectDescriptor(entry);
|
||||
transaction.snapshot.emptyProjects.set(project.projectId, project);
|
||||
const project = normalizeProjectDescriptor(entry);
|
||||
transaction.snapshot.projects.set(project.projectId, project);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
|
||||
cursor = payload.pageInfo.nextCursor;
|
||||
@@ -288,6 +304,15 @@ export class DirectorySync {
|
||||
}
|
||||
}
|
||||
|
||||
private buildLegacyProjectSnapshot(snapshot: WorkspaceDirectorySnapshot): void {
|
||||
for (const workspace of snapshot.workspaces.values()) {
|
||||
if (!snapshot.projects.has(workspace.projectId)) {
|
||||
const project = legacyProjectDescriptorFromWorkspace(workspace);
|
||||
snapshot.projects.set(project.projectId, project);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAll(): Promise<void> {
|
||||
await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]);
|
||||
}
|
||||
@@ -421,6 +446,17 @@ export class DirectorySync {
|
||||
}
|
||||
}
|
||||
|
||||
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
projectKey: null,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
export class DirectoryRefreshSupersededError extends Error {}
|
||||
|
||||
export type { DirectorySourceToken } from "./transaction";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor,
|
||||
normalizeProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
@@ -33,7 +33,7 @@ it("commits workspace and project-parent state with filtered removals", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const empty = normalizeEmptyProjectDescriptor({
|
||||
const empty = normalizeProjectDescriptor({
|
||||
projectId: "empty",
|
||||
projectDisplayName: "Empty",
|
||||
projectRootPath: "/repo/empty",
|
||||
@@ -45,14 +45,14 @@ it("commits workspace and project-parent state with filtered removals", () => {
|
||||
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
|
||||
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
|
||||
]),
|
||||
emptyProjects: new Map([[empty.projectId, empty]]),
|
||||
projects: new Map([[empty.projectId, empty]]),
|
||||
},
|
||||
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
|
||||
);
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]);
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
|
||||
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["empty"]);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
@@ -65,19 +65,19 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
||||
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
|
||||
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
|
||||
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
|
||||
const staleAttachedProject = normalizeEmptyProjectDescriptor({
|
||||
const staleAttachedProject = normalizeProjectDescriptor({
|
||||
projectId: "attached",
|
||||
projectDisplayName: "Stale attached project",
|
||||
projectRootPath: "/repo/attached",
|
||||
projectKind: "git",
|
||||
});
|
||||
const removedProject = normalizeEmptyProjectDescriptor({
|
||||
const removedProject = normalizeProjectDescriptor({
|
||||
projectId: "removed",
|
||||
projectDisplayName: "Removed project",
|
||||
projectRootPath: "/repo/removed",
|
||||
projectKind: "git",
|
||||
});
|
||||
const unchangedEmptyProject = normalizeEmptyProjectDescriptor({
|
||||
const unchangedEmptyProject = normalizeProjectDescriptor({
|
||||
projectId: "unchanged-empty",
|
||||
projectDisplayName: "Unchanged empty project",
|
||||
projectRootPath: "/repo/unchanged-empty",
|
||||
@@ -92,7 +92,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
||||
[removed.id, removed],
|
||||
[unrelated.id, unrelated],
|
||||
]),
|
||||
emptyProjects: new Map([
|
||||
projects: new Map([
|
||||
[staleAttachedProject.projectId, staleAttachedProject],
|
||||
[removedProject.projectId, removedProject],
|
||||
[unchangedEmptyProject.projectId, unchangedEmptyProject],
|
||||
@@ -103,6 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "attached",
|
||||
projectKey: "remote:github.com/acme/attached",
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectCustomName: "Personal name",
|
||||
projectRootPath: "/moved/attached",
|
||||
@@ -135,11 +136,29 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
||||
});
|
||||
expect(session?.workspaces.has(removed.id)).toBe(false);
|
||||
expect(session?.workspaces.get(unrelated.id)).toBe(unrelated);
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["unchanged-empty", "new-empty"]);
|
||||
expect(session?.emptyProjects.get("unchanged-empty")).toBe(unchangedEmptyProject);
|
||||
expect(session?.emptyProjects.get("new-empty")).toMatchObject({
|
||||
expect(Array.from(session?.projects.keys() ?? [])).toEqual([
|
||||
"attached",
|
||||
"unchanged-empty",
|
||||
"new-empty",
|
||||
]);
|
||||
expect(session?.projects.get("unchanged-empty")).toBe(unchangedEmptyProject);
|
||||
expect(session?.projects.get("new-empty")).toMatchObject({
|
||||
projectDisplayName: "New empty project",
|
||||
projectRootPath: "/repo/new-empty",
|
||||
});
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("does not invent a null-key project from a workspace update", () => {
|
||||
const serverId = "workspace-before-project-update";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
|
||||
replica.applyDelta({ kind: "upsert", workspace: workspace("main", "fresh-project") });
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect(session?.workspaces.has("main")).toBe(true);
|
||||
expect(session?.projects.has("fresh-project")).toBe(false);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor,
|
||||
normalizeProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type EmptyProjectDescriptor,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
@@ -20,7 +20,7 @@ type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.up
|
||||
|
||||
export interface WorkspaceDirectorySnapshot {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
}
|
||||
|
||||
function applyProjectDelta(
|
||||
@@ -28,18 +28,17 @@ function applyProjectDelta(
|
||||
delta: ProjectDirectoryDelta,
|
||||
): void {
|
||||
if (delta.kind === "remove") {
|
||||
snapshot.emptyProjects.delete(delta.projectId);
|
||||
snapshot.projects.delete(delta.projectId);
|
||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||
if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const project = normalizeEmptyProjectDescriptor(delta.project);
|
||||
let hasAttachedWorkspace = false;
|
||||
const project = normalizeProjectDescriptor(delta.project);
|
||||
snapshot.projects.set(project.projectId, project);
|
||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||
if (workspace.projectId !== project.projectId) continue;
|
||||
hasAttachedWorkspace = true;
|
||||
snapshot.workspaces.set(workspaceId, {
|
||||
...workspace,
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
@@ -48,8 +47,6 @@ function applyProjectDelta(
|
||||
projectKind: project.projectKind,
|
||||
});
|
||||
}
|
||||
if (hasAttachedWorkspace) snapshot.emptyProjects.delete(project.projectId);
|
||||
else snapshot.emptyProjects.set(project.projectId, project);
|
||||
}
|
||||
|
||||
export class WorkspaceDirectoryReplica {
|
||||
@@ -75,7 +72,7 @@ export class WorkspaceDirectoryReplica {
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
return {
|
||||
workspaces: new Map(session?.workspaces),
|
||||
emptyProjects: new Map(session?.emptyProjects),
|
||||
projects: new Map(session?.projects),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +81,7 @@ export class WorkspaceDirectoryReplica {
|
||||
deltas: readonly WorkspaceDirectoryDelta[],
|
||||
): WorkspaceDirectorySnapshot {
|
||||
const workspaces = new Map(snapshot.workspaces);
|
||||
const emptyProjects = new Map(snapshot.emptyProjects);
|
||||
const projects = new Map(snapshot.projects);
|
||||
for (const [workspaceId, workspace] of workspaces) {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
|
||||
workspaces.delete(workspaceId);
|
||||
@@ -92,16 +89,18 @@ export class WorkspaceDirectoryReplica {
|
||||
}
|
||||
for (const delta of deltas) {
|
||||
if ("projectId" in delta || "project" in delta) {
|
||||
applyProjectDelta({ workspaces, emptyProjects }, delta);
|
||||
applyProjectDelta({ workspaces, projects }, delta);
|
||||
continue;
|
||||
}
|
||||
if (delta.kind === "remove") {
|
||||
workspaces.delete(delta.id);
|
||||
if (delta.emptyProject) {
|
||||
const project = normalizeEmptyProjectDescriptor(delta.emptyProject);
|
||||
emptyProjects.set(project.projectId, project);
|
||||
const project = normalizeProjectDescriptor(delta.emptyProject);
|
||||
projects.set(project.projectId, project);
|
||||
}
|
||||
if (delta.removedProjectId) {
|
||||
projects.delete(delta.removedProjectId);
|
||||
}
|
||||
if (delta.removedProjectId) emptyProjects.delete(delta.removedProjectId);
|
||||
continue;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
|
||||
@@ -109,16 +108,15 @@ export class WorkspaceDirectoryReplica {
|
||||
workspaces.delete(workspace.id);
|
||||
} else {
|
||||
workspaces.set(workspace.id, workspace);
|
||||
emptyProjects.delete(workspace.projectId);
|
||||
}
|
||||
}
|
||||
return { workspaces, emptyProjects };
|
||||
return { workspaces, projects };
|
||||
}
|
||||
|
||||
private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void {
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(this.serverId, snapshot.workspaces);
|
||||
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
|
||||
store.setProjects(this.serverId, snapshot.projects.values());
|
||||
for (const workspaceId of removedWorkspaceIds) {
|
||||
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
|
||||
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor,
|
||||
normalizeProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
@@ -96,8 +96,15 @@ function seedSession(): void {
|
||||
SERVER_ID,
|
||||
new Map([["workspace-1", normalizeWorkspaceDescriptor(workspace())]]),
|
||||
);
|
||||
store.setEmptyProjects(SERVER_ID, [
|
||||
normalizeEmptyProjectDescriptor({
|
||||
store.setProjects(SERVER_ID, [
|
||||
normalizeProjectDescriptor({
|
||||
projectId: "project-1",
|
||||
projectKey: "remote:github.com/getpaseo/paseo",
|
||||
projectDisplayName: "Paseo",
|
||||
projectRootPath: "/repo/paseo",
|
||||
projectKind: "git",
|
||||
}),
|
||||
normalizeProjectDescriptor({
|
||||
projectId: "empty-project",
|
||||
projectDisplayName: "Empty project",
|
||||
projectRootPath: "/repo/empty",
|
||||
@@ -162,7 +169,7 @@ describe("ReplicaCache", () => {
|
||||
expect(session?.hasHydratedWorkspaces).toBe(false);
|
||||
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1"]);
|
||||
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1"]);
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
|
||||
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-1"]);
|
||||
expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
|
||||
expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
|
||||
expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
|
||||
@@ -213,7 +220,7 @@ describe("ReplicaCache", () => {
|
||||
const timelines = session?.agentStreamTail;
|
||||
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]);
|
||||
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]);
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
|
||||
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]);
|
||||
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
|
||||
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
|
||||
});
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
type WorkspaceDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor,
|
||||
normalizeProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
type SessionReplica,
|
||||
type SessionState,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -50,6 +51,7 @@ const StoredHostSchema = z.object({
|
||||
serverId: z.string(),
|
||||
agents: z.array(StoredAgentSchema),
|
||||
workspaces: z.array(WorkspaceDescriptorPayloadSchema),
|
||||
projects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]),
|
||||
emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema),
|
||||
timeline: StoredTimelineSchema.nullable(),
|
||||
});
|
||||
@@ -214,18 +216,47 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
|
||||
};
|
||||
}
|
||||
|
||||
function serializeProject(project: ProjectDescriptor) {
|
||||
return {
|
||||
projectId: project.projectId,
|
||||
...(project.projectKey ? { projectKey: project.projectKey } : {}),
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
projectCustomName: project.projectCustomName,
|
||||
projectRootPath: project.projectRootPath,
|
||||
projectKind: project.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeHost(stored: StoredHost): SessionReplica {
|
||||
const agents = stored.agents.map((entry) => deserializeAgent(stored.serverId, entry));
|
||||
const workspaces = stored.workspaces.map(normalizeWorkspaceDescriptor);
|
||||
const emptyProjects = stored.emptyProjects.map(normalizeEmptyProjectDescriptor);
|
||||
const listedProjects = stored.projects.map(normalizeProjectDescriptor);
|
||||
const legacyProjects = [
|
||||
...stored.emptyProjects.map(normalizeProjectDescriptor),
|
||||
...workspaces.map(legacyProjectDescriptorFromWorkspace),
|
||||
];
|
||||
const projects = new Map(
|
||||
[...legacyProjects, ...listedProjects].map((project) => [project.projectId, project]),
|
||||
);
|
||||
return {
|
||||
agents: new Map(agents.map((agent) => [agent.id, agent])),
|
||||
workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
|
||||
emptyProjects: new Map(emptyProjects.map((project) => [project.projectId, project])),
|
||||
projects,
|
||||
timeline: deserializeTimeline(stored.timeline),
|
||||
};
|
||||
}
|
||||
|
||||
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
projectKey: null,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
export class ReplicaCache {
|
||||
private readonly activeServerIds = new Set<string>();
|
||||
private readonly storedHosts = new Map<string, StoredHost>();
|
||||
@@ -384,6 +415,11 @@ export class ReplicaCache {
|
||||
serverId,
|
||||
agents: focusedAgent ? [serializeAgent(focusedAgent)] : [],
|
||||
workspaces: focusedWorkspace ? [serializeWorkspace(focusedWorkspace)] : [],
|
||||
projects: focusedWorkspace
|
||||
? [session.projects.get(focusedWorkspace.projectId)].flatMap((project) =>
|
||||
project ? [serializeProject(project)] : [],
|
||||
)
|
||||
: [],
|
||||
emptyProjects: [],
|
||||
timeline,
|
||||
};
|
||||
|
||||
@@ -588,6 +588,28 @@ describe("schedule form model", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates an edited schedule's project label when its host targets arrive", () => {
|
||||
const form = open({
|
||||
mode: "edit",
|
||||
schedule: scheduleOnHost({
|
||||
serverId: "host-b",
|
||||
serverName: "Host B",
|
||||
cwd: "/repo/b",
|
||||
model: "model-b",
|
||||
}),
|
||||
defaults: { serverId: null, projectTargets: [], preferences: {} },
|
||||
});
|
||||
|
||||
expect(form.getState().projectDisplay).toEqual({ label: "/repo/b" });
|
||||
|
||||
form.applyProjectTargets(PROJECT_TARGETS);
|
||||
|
||||
expect(form.getState()).toMatchObject({
|
||||
projectDisplay: { label: "Project B" },
|
||||
selectedProjectOptionId: buildProjectOptionId("host-b", "project-b"),
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates late create preferences without overwriting user changes or edited schedules", () => {
|
||||
const savedPreferences: FormPreferences = {
|
||||
provider: "mock",
|
||||
|
||||
@@ -602,6 +602,11 @@ function updateDerivedState(input: {
|
||||
...input.state,
|
||||
hosts: [...input.hosts],
|
||||
projectOptions: buildProjectOptions(input.targets, input.state.selectedServerId),
|
||||
projectDisplay: resolveProjectDisplay({
|
||||
targets: input.targets,
|
||||
serverId: input.state.selectedServerId,
|
||||
cwd: input.state.workingDir,
|
||||
}),
|
||||
selectedProjectOptionId: projectTarget?.optionId ?? input.state.selectedProjectOptionId,
|
||||
selectedModelDisplay: resolveModelDisplay({
|
||||
entries: input.providerEntries,
|
||||
|
||||
@@ -21,6 +21,9 @@ function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
|
||||
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
|
||||
return {
|
||||
serverId: "host-1",
|
||||
projectId: "project-1",
|
||||
projectName: "Project",
|
||||
projectCustomName: null,
|
||||
serverName: "Host 1",
|
||||
isOnline: true,
|
||||
repoRoot: "/tmp/project",
|
||||
@@ -35,14 +38,17 @@ describe("buildScheduleProjectTargets", () => {
|
||||
const targets = buildScheduleProjectTargets([
|
||||
makeProject({
|
||||
projectName: "Alpha",
|
||||
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
|
||||
hosts: [
|
||||
makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" }),
|
||||
makeHost({ serverId: "host-2", projectName: "Alpha on Host 2" }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(targets).toHaveLength(2);
|
||||
expect(targets[0]).toMatchObject({
|
||||
serverId: "host-1",
|
||||
cwd: "/tmp/alpha",
|
||||
projectName: "Alpha",
|
||||
projectName: "Alpha on Host 1",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,12 +66,15 @@ describe("describeScheduleCwd", () => {
|
||||
it("prefers a matched project name and shortens unmatched paths", () => {
|
||||
const byCwd = buildProjectNameByCwd(
|
||||
buildScheduleProjectTargets([
|
||||
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
|
||||
makeProject({
|
||||
projectName: "Grouped Alpha",
|
||||
hosts: [makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" })],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
|
||||
).toBe("Alpha");
|
||||
).toBe("Alpha on Host 1");
|
||||
expect(
|
||||
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
|
||||
).toBe("~/api");
|
||||
|
||||
@@ -37,7 +37,7 @@ export function buildScheduleProjectTargets(
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectName: host.projectName,
|
||||
cwd,
|
||||
isGit: Boolean(host.gitRuntime),
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem {
|
||||
projectName: key,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/work/${key}`,
|
||||
hosts: [{ serverId, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
|
||||
hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
|
||||
workspaceKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import {
|
||||
getHostProjectSourceDirectory,
|
||||
getHostProjectId,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
useHostProjects,
|
||||
@@ -823,6 +824,8 @@ async function createMultiplicityWorkspace(input: {
|
||||
serverId: string;
|
||||
createFailedMessage: string;
|
||||
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
|
||||
const projectId = getHostProjectId(input.project, input.serverId);
|
||||
if (!projectId) throw new Error("Project is not available on the selected host");
|
||||
const isWorktree = input.isolation === "worktree";
|
||||
const checkoutRequest = isWorktree
|
||||
? resolveCheckoutRequest(input.selectedItem, input.currentBranch)
|
||||
@@ -836,14 +839,14 @@ async function createMultiplicityWorkspace(input: {
|
||||
? {
|
||||
kind: "worktree",
|
||||
cwd: input.sourceDirectory,
|
||||
projectId: input.project.projectKey,
|
||||
projectId,
|
||||
worktreeSlug: createNameId(),
|
||||
...checkoutRequest,
|
||||
}
|
||||
: {
|
||||
kind: "directory",
|
||||
path: input.sourceDirectory,
|
||||
projectId: input.project.projectKey,
|
||||
projectId,
|
||||
},
|
||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||
});
|
||||
@@ -1411,6 +1414,8 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
>
|
||||
<Pressable
|
||||
ref={host.anchorRef}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Host"
|
||||
onPress={host.open}
|
||||
disabled={isPending || host.allHosts.length === 0}
|
||||
style={badgePressableStyle}
|
||||
@@ -1889,16 +1894,20 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
|
||||
const firstAgentContext = buildFirstAgentContext(input);
|
||||
const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
|
||||
if (!hostProjectId) {
|
||||
throw new Error("Project is not available on the selected host");
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: selectedSourceDirectory,
|
||||
projectId: selectedProject.projectKey,
|
||||
projectId: hostProjectId,
|
||||
worktreeSlug: createNameId(),
|
||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||
...checkoutRequest,
|
||||
};
|
||||
},
|
||||
[currentBranch, selectedItem, selectedProject, selectedSourceDirectory],
|
||||
[currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory],
|
||||
);
|
||||
|
||||
const ensureWorkspace = useCallback(
|
||||
|
||||
@@ -17,7 +17,14 @@ function project(projectKey: string, serverId = "host"): HostProjectListItem {
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/work/${projectKey}`,
|
||||
hosts: [{ serverId, iconWorkingDir: `/work/${projectKey}`, canCreateWorktree: true }],
|
||||
hosts: [
|
||||
{
|
||||
serverId,
|
||||
projectId: projectKey,
|
||||
iconWorkingDir: `/work/${projectKey}`,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaceKeys: [],
|
||||
};
|
||||
}
|
||||
@@ -111,6 +118,46 @@ describe("reconcileProjectSelection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adopts a routed project's hydrated cross-host key", () => {
|
||||
const routedProject = {
|
||||
...project("local-project-id"),
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host",
|
||||
projectId: "local-project-id",
|
||||
iconWorkingDir: "/work/project",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const hydratedProject = {
|
||||
...project("remote:github.com/acme/project"),
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host",
|
||||
projectId: "local-project-id",
|
||||
iconWorkingDir: "/work/project",
|
||||
canCreateWorktree: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: routedProject, projects: [], routeProject: routedProject }),
|
||||
);
|
||||
const hydratedContext = context({
|
||||
initialProject: hydratedProject,
|
||||
projects: [hydratedProject],
|
||||
routeProject: routedProject,
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: hydratedProject.projectKey,
|
||||
project: hydratedProject,
|
||||
source: "initial",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores hydrated project snapshots before archive gaps", () => {
|
||||
const routeProject = project("route-project");
|
||||
const hydratedProject: HostProjectListItem = {
|
||||
@@ -184,6 +231,17 @@ describe("reconcileProjectSelection", () => {
|
||||
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
|
||||
});
|
||||
|
||||
it("preserves an opaque project key ending in whitespace", () => {
|
||||
const selected = project("host:project ");
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: selected, projects: [selected] }),
|
||||
);
|
||||
const currentContext = context({ initialProject: selected, projects: [selected] });
|
||||
|
||||
expect(resolveProjectSelection(current, currentContext)).toEqual(selected);
|
||||
expect(reconcileProjectSelection(current, currentContext)).toEqual(current);
|
||||
});
|
||||
|
||||
it("resets fallback selection when host project capability changes", () => {
|
||||
const fallback = project("git-fallback");
|
||||
const remembered = project("remembered-directory");
|
||||
|
||||
@@ -57,7 +57,17 @@ export function resolveInitialProjectSelectionSource(input: {
|
||||
if (!input.initialProject) {
|
||||
return null;
|
||||
}
|
||||
if (input.routeProject?.projectKey === input.initialProject.projectKey) {
|
||||
if (
|
||||
input.routeProject?.projectKey === input.initialProject.projectKey ||
|
||||
input.routeProject?.hosts.some((routeHost) =>
|
||||
input.initialProject?.hosts.some(
|
||||
(host) =>
|
||||
host.serverId === routeHost.serverId &&
|
||||
Boolean(host.projectId) &&
|
||||
host.projectId === routeHost.projectId,
|
||||
),
|
||||
)
|
||||
) {
|
||||
return "route";
|
||||
}
|
||||
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
|
||||
@@ -67,8 +77,8 @@ export function resolveInitialProjectSelectionSource(input: {
|
||||
}
|
||||
|
||||
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
|
||||
const projectKey = selection.projectKey?.trim() ?? "";
|
||||
return projectKey || null;
|
||||
const projectKey = selection.projectKey ?? "";
|
||||
return projectKey.trim() ? projectKey : null;
|
||||
}
|
||||
|
||||
function resolveSelectedProjectFromInitialInputs(
|
||||
@@ -95,14 +105,14 @@ function refreshSelectionProject(
|
||||
};
|
||||
}
|
||||
|
||||
function shouldResetInitialFallbackSelection(
|
||||
function shouldResetHydratedInitialSelection(
|
||||
selection: ProjectSelection,
|
||||
context: ProjectSelectionContext,
|
||||
): boolean {
|
||||
if (
|
||||
selection.source !== "initial" ||
|
||||
!context.initialProject ||
|
||||
context.initialProjectSource !== "lastActive"
|
||||
(context.initialProjectSource !== "route" && context.initialProjectSource !== "lastActive")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -149,7 +159,7 @@ export function reconcileProjectSelection(
|
||||
return initialSelection;
|
||||
}
|
||||
|
||||
if (shouldResetInitialFallbackSelection(current, context)) {
|
||||
if (shouldResetHydratedInitialSelection(current, context)) {
|
||||
return initialSelection;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { resolveProjectSettingsHostTarget } from "./project-settings-host-selection";
|
||||
|
||||
test("falls back when the selected host is no longer editable", () => {
|
||||
const hostA = { serverId: "host-a", projectId: "project-a" };
|
||||
const hostB = { serverId: "host-b", projectId: "project-b" };
|
||||
|
||||
expect(
|
||||
resolveProjectSettingsHostTarget({
|
||||
routeKey: "host-a:project-a",
|
||||
editableHosts: [hostA],
|
||||
defaultHost: hostA,
|
||||
selection: { routeKey: "host-a:project-a", ...hostB },
|
||||
}),
|
||||
).toEqual(hostA);
|
||||
});
|
||||
|
||||
test("keeps the selected host while it remains editable", () => {
|
||||
const hostA = { serverId: "host-a", projectId: "project-a" };
|
||||
const hostB = { serverId: "host-b", projectId: "project-b" };
|
||||
|
||||
expect(
|
||||
resolveProjectSettingsHostTarget({
|
||||
routeKey: "host-a:project-a",
|
||||
editableHosts: [hostA, hostB],
|
||||
defaultHost: hostA,
|
||||
selection: { routeKey: "host-a:project-a", ...hostB },
|
||||
}),
|
||||
).toEqual(hostB);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface ProjectSettingsHostTarget {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface StoredProjectSettingsHostSelection extends ProjectSettingsHostTarget {
|
||||
routeKey: string;
|
||||
}
|
||||
|
||||
interface ResolveProjectSettingsHostTargetInput {
|
||||
routeKey: string;
|
||||
editableHosts: readonly ProjectSettingsHostTarget[];
|
||||
defaultHost: ProjectSettingsHostTarget | undefined;
|
||||
selection: StoredProjectSettingsHostSelection;
|
||||
}
|
||||
|
||||
export function resolveProjectSettingsHostTarget(
|
||||
input: ResolveProjectSettingsHostTargetInput,
|
||||
): ProjectSettingsHostTarget | undefined {
|
||||
const selectedHost =
|
||||
input.selection.routeKey === input.routeKey
|
||||
? getEditableHost(input.editableHosts, input.selection)
|
||||
: undefined;
|
||||
return (
|
||||
selectedHost ??
|
||||
getEditableHost(input.editableHosts, input.defaultHost) ??
|
||||
input.editableHosts[0]
|
||||
);
|
||||
}
|
||||
|
||||
function getEditableHost(
|
||||
editableHosts: readonly ProjectSettingsHostTarget[],
|
||||
target: ProjectSettingsHostTarget | undefined,
|
||||
): ProjectSettingsHostTarget | undefined {
|
||||
if (!target) return undefined;
|
||||
for (const host of editableHosts) {
|
||||
if (host.serverId === target.serverId && host.projectId === target.projectId) return host;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
} from "@/utils/project-config-form";
|
||||
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
|
||||
import { getProjectHostEntry, getProjectSummaryForHostProject } from "@/utils/projects";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { selectProjectIdForServer } from "@/stores/session-store-hooks/selectors";
|
||||
import { resolveProjectSettingsHostTarget } from "@/screens/project-settings-host-selection";
|
||||
|
||||
const SCRIPT_SERVICE_TYPE = "service";
|
||||
|
||||
@@ -84,35 +88,54 @@ const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees";
|
||||
type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"]>>;
|
||||
|
||||
export interface ProjectSettingsScreenProps {
|
||||
projectKey: string;
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
|
||||
export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSettingsScreenProps) {
|
||||
const { projects } = useProjects();
|
||||
const project = useMemo(
|
||||
() => projects.find((entry) => entry.projectKey === projectKey),
|
||||
[projects, projectKey],
|
||||
() => getProjectSummaryForHostProject(projects, serverId, projectId),
|
||||
[projectId, projects, serverId],
|
||||
);
|
||||
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>(
|
||||
() => editableHosts[0]?.serverId ?? "",
|
||||
const routeKey = `${serverId}:${projectId}`;
|
||||
const routeHost = getProjectHostEntry(project, serverId, projectId);
|
||||
const defaultHost = routeHost?.isOnline ? routeHost : editableHosts[0];
|
||||
const [hostSelection, setHostSelection] = useState({
|
||||
routeKey: "",
|
||||
serverId: "",
|
||||
projectId: "",
|
||||
});
|
||||
const selectedTarget = resolveProjectSettingsHostTarget({
|
||||
routeKey,
|
||||
editableHosts,
|
||||
defaultHost,
|
||||
selection: hostSelection,
|
||||
});
|
||||
const selectedServerId = selectedTarget?.serverId ?? "";
|
||||
const selectedProjectId = selectedTarget?.projectId ?? "";
|
||||
const setSelectedServerId = useCallback(
|
||||
(targetServerId: string) => {
|
||||
const targetProjectId = selectProjectIdForServer(useSessionStore.getState(), {
|
||||
sourceServerId: serverId,
|
||||
projectId,
|
||||
targetServerId,
|
||||
});
|
||||
if (targetProjectId) {
|
||||
setHostSelection({ routeKey, serverId: targetServerId, projectId: targetProjectId });
|
||||
}
|
||||
},
|
||||
[projectId, routeKey, serverId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stillValid = editableHosts.some((host) => host.serverId === selectedServerId);
|
||||
if (!stillValid) {
|
||||
setSelectedServerId(editableHosts[0]?.serverId ?? "");
|
||||
}
|
||||
}, [editableHosts, selectedServerId]);
|
||||
|
||||
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
|
||||
const isHostGone =
|
||||
Boolean(selectedServerId) &&
|
||||
(selectedSnapshot?.connectionStatus === "offline" ||
|
||||
selectedSnapshot?.connectionStatus === "error");
|
||||
|
||||
const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId);
|
||||
const selectedHost = getProjectHostEntry(project, selectedServerId, selectedProjectId);
|
||||
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
|
||||
|
||||
if (!project || editableHosts.length === 0 || !selectedHost || !client) {
|
||||
@@ -231,7 +254,7 @@ function ProjectSettingsBody({
|
||||
const hasMultipleHosts = hosts.length > 1;
|
||||
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
<View role="main" style={styles.body}>
|
||||
<BackToProjectsButton />
|
||||
|
||||
<View style={styles.headerBlock}>
|
||||
@@ -241,7 +264,13 @@ function ProjectSettingsBody({
|
||||
projectName={project.projectName}
|
||||
projectKey={project.projectKey}
|
||||
/>
|
||||
<ProjectNameEditor project={project} client={client} />
|
||||
<ProjectNameEditor
|
||||
key={`${selectedHost.serverId}:${selectedHost.projectId}`}
|
||||
projectName={selectedHost.projectName}
|
||||
projectCustomName={selectedHost.projectCustomName}
|
||||
projectId={selectedHost.projectId}
|
||||
client={client}
|
||||
/>
|
||||
</View>
|
||||
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
|
||||
</View>
|
||||
@@ -789,19 +818,26 @@ function ResolveSpinnerColor(): string {
|
||||
}
|
||||
|
||||
interface ProjectNameEditorProps {
|
||||
project: ProjectSummary;
|
||||
projectName: string;
|
||||
projectCustomName: string | null;
|
||||
projectId: string;
|
||||
client: DaemonClient;
|
||||
}
|
||||
|
||||
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
function ProjectNameEditor({
|
||||
projectName,
|
||||
projectCustomName,
|
||||
projectId,
|
||||
client,
|
||||
}: ProjectNameEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(project.projectCustomName ?? "");
|
||||
const [value, setValue] = useState(projectCustomName ?? "");
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
|
||||
mutationFn: (customName: string | null) => client.renameProject(projectId, customName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
setIsEditing(false);
|
||||
@@ -815,24 +851,24 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
});
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
setValue(project.projectCustomName ?? "");
|
||||
setValue(projectCustomName ?? "");
|
||||
setIsEditing(true);
|
||||
}, [project.projectCustomName]);
|
||||
}, [projectCustomName]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setValue(project.projectCustomName ?? "");
|
||||
}, [project.projectCustomName]);
|
||||
setValue(projectCustomName ?? "");
|
||||
}, [projectCustomName]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
const next = trimmed.length === 0 ? null : trimmed;
|
||||
if (next === (project.projectCustomName ?? null)) {
|
||||
if (next === projectCustomName) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
renameMutation.mutate(next);
|
||||
}, [value, project.projectCustomName, renameMutation]);
|
||||
}, [value, projectCustomName, renameMutation]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
renameMutation.mutate(null);
|
||||
@@ -840,12 +876,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<View style={styles.nameEditorRow}>
|
||||
<View
|
||||
role="group"
|
||||
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||
style={styles.nameEditorRow}
|
||||
>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{project.projectName}
|
||||
{projectName}
|
||||
</Text>
|
||||
<Pressable
|
||||
testID="project-name-edit-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.rename.renameLabel")}
|
||||
onPress={handleStartEdit}
|
||||
hitSlop={8}
|
||||
@@ -853,9 +894,10 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
>
|
||||
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
{project.projectCustomName ? (
|
||||
{projectCustomName ? (
|
||||
<Pressable
|
||||
testID="project-name-reset-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.rename.resetLabel")}
|
||||
onPress={handleReset}
|
||||
disabled={renameMutation.isPending}
|
||||
@@ -870,13 +912,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.nameEditorRow}>
|
||||
<View
|
||||
role="group"
|
||||
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||
style={styles.nameEditorRow}
|
||||
>
|
||||
<TextInput
|
||||
testID="project-name-input"
|
||||
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={project.projectName}
|
||||
placeholder={projectName}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
autoFocus
|
||||
style={styles.nameEditorInput}
|
||||
@@ -886,6 +932,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
/>
|
||||
<Pressable
|
||||
testID="project-name-save-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.rename.saveLabel")}
|
||||
onPress={handleSave}
|
||||
disabled={renameMutation.isPending}
|
||||
@@ -896,6 +943,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="project-name-cancel-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.rename.cancelLabel")}
|
||||
onPress={handleCancel}
|
||||
disabled={renameMutation.isPending}
|
||||
@@ -982,6 +1030,7 @@ function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
|
||||
>
|
||||
<Pressable
|
||||
ref={triggerRef}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.switchHost")}
|
||||
testID="host-picker"
|
||||
style={styles.hostIndicator}
|
||||
|
||||
@@ -221,6 +221,9 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
|
||||
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
|
||||
return {
|
||||
serverId: "host-a",
|
||||
projectId: "project-a",
|
||||
projectName: "Project",
|
||||
projectCustomName: null,
|
||||
serverName: "alpha",
|
||||
isOnline: true,
|
||||
repoRoot: "/home/me/proj",
|
||||
@@ -290,7 +293,9 @@ describe("ProjectsScreen", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
|
||||
function render(
|
||||
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string },
|
||||
) {
|
||||
act(() => {
|
||||
root?.render(<ProjectsScreen view={view} />);
|
||||
});
|
||||
@@ -329,7 +334,7 @@ describe("ProjectsScreen", () => {
|
||||
});
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
|
||||
expect(navigate).toHaveBeenCalledWith("/settings/projects/host-a/project-a");
|
||||
});
|
||||
|
||||
it("does not render a kebab menu on the row", () => {
|
||||
@@ -392,7 +397,7 @@ describe("ProjectsScreen", () => {
|
||||
],
|
||||
});
|
||||
|
||||
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
|
||||
render({ kind: "project", serverId: "host-a", projectId: "project-a" });
|
||||
|
||||
const selected = findRow(container!, "remote:github.com/acme/app");
|
||||
const other = findRow(container!, "remote:github.com/acme/other");
|
||||
|
||||
@@ -10,16 +10,20 @@ import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects";
|
||||
|
||||
interface ProjectsScreenProps {
|
||||
view: { kind: "projects" } | { kind: "project"; projectKey: string };
|
||||
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string };
|
||||
}
|
||||
|
||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
const { t } = useTranslation();
|
||||
const { projects, hostErrors, isLoading } = useProjects();
|
||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||
const selectedProjectKey =
|
||||
view.kind === "project"
|
||||
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ??
|
||||
null)
|
||||
: null;
|
||||
const iconTargets = useMemo(
|
||||
() =>
|
||||
projects.flatMap((project) => {
|
||||
@@ -100,10 +104,10 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { projectKey, projectName } = project;
|
||||
|
||||
const target = project.hosts[0];
|
||||
const handleNavigate = useCallback(() => {
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId));
|
||||
}, [target]);
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
|
||||
@@ -129,7 +129,7 @@ export type SettingsView =
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
||||
| { kind: "projects" }
|
||||
| { kind: "project"; projectKey: string };
|
||||
| { kind: "project"; serverId: string; projectId: string };
|
||||
|
||||
interface SidebarSectionItem {
|
||||
id: SettingsSectionSlug;
|
||||
@@ -1386,7 +1386,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
return <ProjectsScreen view={view} />;
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return <ProjectSettingsScreen projectKey={view.projectKey} />;
|
||||
return <ProjectSettingsScreen serverId={view.serverId} projectId={view.projectId} />;
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
switch (view.section) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "./selectors";
|
||||
import {
|
||||
useSessionStore,
|
||||
type EmptyProjectDescriptor,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "../session-store";
|
||||
|
||||
@@ -45,11 +45,33 @@ function createWorkspace(
|
||||
};
|
||||
}
|
||||
|
||||
function projectDescriptorFromTestWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setProjects(
|
||||
SERVER_ID,
|
||||
new Map(
|
||||
workspaces.map((workspace) => [
|
||||
workspace.projectId,
|
||||
projectDescriptorFromTestWorkspace(workspace),
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
}
|
||||
|
||||
interface Subscribable<S> {
|
||||
@@ -125,11 +147,26 @@ describe("workspace replica authority", () => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map([[authoritativeWorkspace.id, authoritativeWorkspace]]));
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setProjects(
|
||||
SERVER_ID,
|
||||
new Map([
|
||||
[
|
||||
authoritativeWorkspace.projectId,
|
||||
projectDescriptorFromTestWorkspace(authoritativeWorkspace),
|
||||
],
|
||||
]).values(),
|
||||
);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
const hydratedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
|
||||
SERVER_ID,
|
||||
]);
|
||||
|
||||
expect(hydratedServerIds).toEqual([SERVER_ID]);
|
||||
expect(
|
||||
Array.from(useSessionStore.getState().sessions[SERVER_ID]?.projects.keys() ?? []),
|
||||
).toEqual([authoritativeWorkspace.projectId]);
|
||||
expect(
|
||||
selectWorkspaceStructureProjects(useSessionStore.getState(), hydratedServerIds).map(
|
||||
(project) => project.workspaceKeys,
|
||||
@@ -149,10 +186,16 @@ describe("workspace replica authority", () => {
|
||||
sessions: {
|
||||
[loadingServerId]: {
|
||||
hasHydratedWorkspaces: false,
|
||||
projects: new Map([
|
||||
[loadingWorkspace.projectId, projectDescriptorFromTestWorkspace(loadingWorkspace)],
|
||||
]),
|
||||
workspaces: new Map([[loadingWorkspace.id, loadingWorkspace]]),
|
||||
},
|
||||
[hydratedServerId]: {
|
||||
hasHydratedWorkspaces: true,
|
||||
projects: new Map([
|
||||
[hydratedWorkspace.projectId, projectDescriptorFromTestWorkspace(hydratedWorkspace)],
|
||||
]),
|
||||
workspaces: new Map([[hydratedWorkspace.id, hydratedWorkspace]]),
|
||||
},
|
||||
},
|
||||
@@ -295,8 +338,9 @@ describe("workspace structure composition", () => {
|
||||
projectRootPath: "/repo/a",
|
||||
workspaceDirectory: "/repo/a",
|
||||
});
|
||||
const emptyProject: EmptyProjectDescriptor = {
|
||||
const emptyProject: ProjectDescriptor = {
|
||||
projectId: "project-a",
|
||||
projectKey: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/a",
|
||||
@@ -311,7 +355,7 @@ describe("workspace structure composition", () => {
|
||||
|
||||
try {
|
||||
useSessionStore.getState().removeWorkspace(SERVER_ID, workspace.id);
|
||||
useSessionStore.getState().addEmptyProject(SERVER_ID, emptyProject);
|
||||
useSessionStore.getState().upsertProject(SERVER_ID, emptyProject);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
@@ -348,15 +392,15 @@ describe("workspace structure composition", () => {
|
||||
it("renders a project parent with zero active workspaces", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
|
||||
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
|
||||
{
|
||||
projectId: "empty-project",
|
||||
projectDisplayName: "Empty Project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/empty",
|
||||
projectKind: "git",
|
||||
},
|
||||
]);
|
||||
const emptyProject: ProjectDescriptor = {
|
||||
projectId: "empty-project",
|
||||
projectKey: "empty-project",
|
||||
projectDisplayName: "Empty Project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/empty",
|
||||
projectKind: "git",
|
||||
};
|
||||
useSessionStore.getState().setProjects(SERVER_ID, [emptyProject]);
|
||||
|
||||
const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), [SERVER_ID]);
|
||||
expect(projects).toEqual([
|
||||
@@ -368,7 +412,7 @@ describe("workspace structure composition", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("changes when a structure-relevant project identity field changes", () => {
|
||||
it("changes when a project descriptor display field changes", () => {
|
||||
const workspace = createWorkspace({
|
||||
id: "workspace-a",
|
||||
projectDisplayName: "Project 1",
|
||||
@@ -382,9 +426,10 @@ describe("workspace structure composition", () => {
|
||||
);
|
||||
const before = tracked.current;
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
|
||||
useSessionStore.getState().upsertProject(SERVER_ID, {
|
||||
...projectDescriptorFromTestWorkspace(workspace),
|
||||
projectDisplayName: "Project Renamed",
|
||||
});
|
||||
expect(tracked.current).not.toBe(before);
|
||||
|
||||
tracked.stop();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/projects/workspace-structure";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
||||
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
||||
|
||||
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
@@ -17,7 +17,7 @@ export interface SessionsSnapshot {
|
||||
{
|
||||
hasHydratedWorkspaces?: boolean;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects?: Map<string, EmptyProjectDescriptor>;
|
||||
projects?: Map<string, ProjectDescriptor>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
@@ -145,20 +145,20 @@ export function selectWorkspaceStructureProjects(
|
||||
const sessions: Array<{
|
||||
serverId: string;
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
emptyProjects: Iterable<EmptyProjectDescriptor>;
|
||||
projects: Iterable<ProjectDescriptor>;
|
||||
}> = [];
|
||||
|
||||
for (const serverId of serverIds) {
|
||||
const session = state.sessions[serverId];
|
||||
const workspaces = session?.workspaces;
|
||||
const emptyProjects = session?.emptyProjects;
|
||||
if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) {
|
||||
const projects = session?.projects;
|
||||
if (!projects || projects.size === 0) {
|
||||
continue;
|
||||
}
|
||||
sessions.push({
|
||||
serverId,
|
||||
workspaces: workspaces?.values() ?? [],
|
||||
emptyProjects: emptyProjects?.values() ?? [],
|
||||
projects: projects.values(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,6 +169,32 @@ export function selectWorkspaceStructureProjects(
|
||||
return buildWorkspaceStructureProjects({ sessions });
|
||||
}
|
||||
|
||||
export function selectProject(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
projectId: string | null,
|
||||
): ProjectDescriptor | null {
|
||||
if (!serverId || !projectId) return null;
|
||||
return state.sessions[serverId]?.projects?.get(projectId) ?? null;
|
||||
}
|
||||
|
||||
export function selectProjectIdForServer(
|
||||
state: SessionsSnapshot,
|
||||
input: {
|
||||
sourceServerId: string;
|
||||
projectId: string;
|
||||
targetServerId: string;
|
||||
},
|
||||
): string | null {
|
||||
if (input.sourceServerId === input.targetServerId) return input.projectId;
|
||||
const source = selectProject(state, input.sourceServerId, input.projectId);
|
||||
if (!source?.projectKey) return null;
|
||||
for (const project of state.sessions[input.targetServerId]?.projects?.values() ?? []) {
|
||||
if (project.projectKey === source.projectKey) return project.projectId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
|
||||
return state.projectOrder ?? EMPTY_WORKSPACE_KEYS;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ function getTestSessionReferences() {
|
||||
sessions: state.sessions,
|
||||
session,
|
||||
workspaces: session.workspaces,
|
||||
emptyProjects: session.emptyProjects,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,48 +404,6 @@ describe("removeWorkspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeEmptyProject", () => {
|
||||
it("removes an empty project by project id", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setEmptyProjects("test-server", [
|
||||
{
|
||||
projectId: "project-empty",
|
||||
projectDisplayName: "Empty",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/empty",
|
||||
projectKind: "git",
|
||||
},
|
||||
]);
|
||||
|
||||
store.removeEmptyProject("test-server", "project-empty");
|
||||
|
||||
expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves identity when removing a missing empty project", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setEmptyProjects("test-server", [
|
||||
{
|
||||
projectId: "project-empty",
|
||||
projectDisplayName: "Empty",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/empty",
|
||||
projectKind: "git",
|
||||
},
|
||||
]);
|
||||
const before = getTestSessionReferences();
|
||||
|
||||
store.removeEmptyProject("test-server", "project-missing");
|
||||
const after = getTestSessionReferences();
|
||||
|
||||
expect(after.sessions).toBe(before.sessions);
|
||||
expect(after.session).toBe(before.session);
|
||||
expect(after.emptyProjects).toBe(before.emptyProjects);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchWorkspaceScripts", () => {
|
||||
it("preserves workspace entry identity when scripts are content-equal", () => {
|
||||
const script = {
|
||||
|
||||
@@ -182,19 +182,21 @@ export function normalizeWorkspaceDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmptyProjectDescriptor {
|
||||
export interface ProjectDescriptor {
|
||||
projectId: string;
|
||||
projectKey?: string | null;
|
||||
projectDisplayName: string;
|
||||
projectCustomName: string | null;
|
||||
projectRootPath: string;
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
}
|
||||
|
||||
export function normalizeEmptyProjectDescriptor(
|
||||
export function normalizeProjectDescriptor(
|
||||
payload: WorkspaceProjectDescriptorPayload,
|
||||
): EmptyProjectDescriptor {
|
||||
): ProjectDescriptor {
|
||||
return {
|
||||
projectId: payload.projectId,
|
||||
projectKey: payload.projectKey ?? null,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
@@ -237,28 +239,15 @@ function preserveWorkspaceMapIdentity(
|
||||
return changed ? next : existing;
|
||||
}
|
||||
|
||||
function emptyProjectDescriptorFromWorkspace(
|
||||
workspace: WorkspaceDescriptor,
|
||||
): EmptyProjectDescriptor {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
function hasWorkspaceInProject(
|
||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor>,
|
||||
projectId: string,
|
||||
function projectMapsEqual(
|
||||
left: ReadonlyMap<string, ProjectDescriptor>,
|
||||
right: ReadonlyMap<string, ProjectDescriptor>,
|
||||
): boolean {
|
||||
for (const workspace of workspaces.values()) {
|
||||
if (workspace.projectId === projectId) {
|
||||
return true;
|
||||
}
|
||||
if (left.size !== right.size) return false;
|
||||
for (const [projectId, project] of right) {
|
||||
if (!equal(left.get(projectId), project)) return false;
|
||||
}
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
@@ -332,7 +321,7 @@ export interface SessionReplicaTimeline {
|
||||
export interface SessionReplica {
|
||||
agents: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
timeline: SessionReplicaTimeline | null;
|
||||
}
|
||||
|
||||
@@ -383,9 +372,8 @@ export interface SessionState {
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
agentDetails: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
// `emptyProjects` name is the existing protocol/store projection.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
// All active project descriptors, keyed by host-local projectId.
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
// Transient restore state for archived workspaces, keyed by normalized
|
||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
||||
@@ -512,9 +500,9 @@ interface SessionStoreActions {
|
||||
) => void;
|
||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
||||
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||
setProjects: (serverId: string, projects: Iterable<ProjectDescriptor>) => void;
|
||||
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||
removeProject: (serverId: string, projectId: string) => void;
|
||||
setWorkspaceRestoreStatus: (
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
@@ -599,7 +587,7 @@ function createInitialSessionState(
|
||||
workspaceAgentActivity: new Map(),
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
projects: new Map(),
|
||||
restoringWorkspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
@@ -728,7 +716,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
agents: replica.agents,
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
|
||||
workspaces: replica.workspaces,
|
||||
emptyProjects: replica.emptyProjects,
|
||||
projects: replica.projects,
|
||||
agentStreamTail,
|
||||
agentTimelineCursor,
|
||||
agentTimelineHasOlder,
|
||||
@@ -1363,65 +1351,41 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setEmptyProjects: (serverId, emptyProjects) => {
|
||||
const next = new Map<string, EmptyProjectDescriptor>();
|
||||
for (const project of emptyProjects) {
|
||||
next.set(project.projectId, project);
|
||||
}
|
||||
setProjects: (serverId, projects) => {
|
||||
const next = new Map<string, ProjectDescriptor>();
|
||||
for (const project of projects) next.set(project.projectId, project);
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (session.emptyProjects.size === 0 && next.size === 0) {
|
||||
return prev;
|
||||
}
|
||||
if (!session || projectMapsEqual(session.projects, next)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, emptyProjects: next },
|
||||
},
|
||||
sessions: { ...prev.sessions, [serverId]: { ...session, projects: next } },
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addEmptyProject: (serverId, emptyProject) => {
|
||||
upsertProject: (serverId, project) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const existing = session.emptyProjects.get(emptyProject.projectId);
|
||||
if (existing && equal(existing, emptyProject)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.emptyProjects);
|
||||
next.set(emptyProject.projectId, emptyProject);
|
||||
if (!session || equal(session.projects.get(project.projectId), project)) return prev;
|
||||
const projects = new Map(session.projects);
|
||||
projects.set(project.projectId, project);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, emptyProjects: next },
|
||||
},
|
||||
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeEmptyProject: (serverId, projectId) => {
|
||||
removeProject: (serverId, projectId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session?.emptyProjects.has(projectId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.emptyProjects);
|
||||
next.delete(projectId);
|
||||
if (!session?.projects.has(projectId)) return prev;
|
||||
const projects = new Map(session.projects);
|
||||
projects.delete(projectId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, emptyProjects: next },
|
||||
},
|
||||
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -1483,17 +1447,10 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
const next = new Map(session.workspaces);
|
||||
let changed = false;
|
||||
// A workspace landing in a project means that project is no longer
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
||||
// A descriptor arriving is the success signal for a pending restore:
|
||||
// clear it at the source so every entry point converges to "ready".
|
||||
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
||||
for (const workspace of nextEntries) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
changed = true;
|
||||
}
|
||||
if (session.restoringWorkspaces.has(workspace.id)) {
|
||||
nextRestoring ??= new Map(session.restoringWorkspaces);
|
||||
nextRestoring.delete(workspace.id);
|
||||
@@ -1517,7 +1474,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
[serverId]: {
|
||||
...session,
|
||||
workspaces: next,
|
||||
emptyProjects: nextEmptyProjects,
|
||||
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
||||
},
|
||||
},
|
||||
@@ -1535,31 +1491,13 @@ export const useSessionStore = create<SessionStore>()(
|
||||
if (!session || !workspaceKey) {
|
||||
return prev;
|
||||
}
|
||||
const removedWorkspace = session.workspaces.get(workspaceKey);
|
||||
if (!removedWorkspace) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.workspaces);
|
||||
next.delete(workspaceKey);
|
||||
let nextEmptyProjects = session.emptyProjects;
|
||||
if (hasWorkspaceInProject(next, removedWorkspace.projectId)) {
|
||||
if (nextEmptyProjects.has(removedWorkspace.projectId)) {
|
||||
nextEmptyProjects = new Map(nextEmptyProjects);
|
||||
nextEmptyProjects.delete(removedWorkspace.projectId);
|
||||
}
|
||||
} else {
|
||||
const emptyProject = emptyProjectDescriptorFromWorkspace(removedWorkspace);
|
||||
const existing = nextEmptyProjects.get(emptyProject.projectId);
|
||||
if (!existing || !equal(existing, emptyProject)) {
|
||||
nextEmptyProjects = new Map(nextEmptyProjects);
|
||||
nextEmptyProjects.set(emptyProject.projectId, emptyProject);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
|
||||
[serverId]: { ...session, workspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
useSessionStore,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
|
||||
function projectFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
// A daemon always derives a key for every project, so fixtures carry one too.
|
||||
// Same key on two hosts means the same project, which is what grouping asserts.
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a session the way a daemon populates it: every workspace's project is
|
||||
* published on the project channel, so the store never holds a workspace whose
|
||||
* project is unknown. Tests that seed workspaces alone model a state the daemon
|
||||
* cannot produce, and nothing renders.
|
||||
*/
|
||||
export function seedSessionWorkspaces(
|
||||
serverId: string,
|
||||
workspaces: Map<string, WorkspaceDescriptor>,
|
||||
projects?: Iterable<ProjectDescriptor>,
|
||||
): void {
|
||||
const byProjectId = new Map<string, ProjectDescriptor>();
|
||||
for (const project of projects ?? []) byProjectId.set(project.projectId, project);
|
||||
for (const workspace of workspaces.values()) {
|
||||
if (!byProjectId.has(workspace.projectId)) {
|
||||
byProjectId.set(workspace.projectId, projectFromWorkspace(workspace));
|
||||
}
|
||||
}
|
||||
const store = useSessionStore.getState();
|
||||
store.setProjects(serverId, byProjectId.values());
|
||||
store.setWorkspaces(serverId, workspaces);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
encodeWorkspaceIdForPathSegment,
|
||||
isSettingsSectionSlug,
|
||||
normalizeHostSectionSlug,
|
||||
normalizeProjectSettingsRouteId,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostWorkspaceOpenIntentFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
@@ -196,23 +197,14 @@ describe("projects settings routes", () => {
|
||||
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
|
||||
});
|
||||
|
||||
it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => {
|
||||
expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe(
|
||||
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
|
||||
it("buildProjectSettingsRoute addresses a host-local project id", () => {
|
||||
expect(buildProjectSettingsRoute("host a", "project/1")).toBe(
|
||||
"/settings/projects/host%20a/project%2F1",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildProjectSettingsRoute encodes a local repo-root key", () => {
|
||||
expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe(
|
||||
"/settings/projects/%2FUsers%2Fme%2Fdev%2Fpaseo",
|
||||
);
|
||||
});
|
||||
|
||||
it("project keys round-trip through decodeURIComponent", () => {
|
||||
const projectKey = "remote:github.com/acme/app";
|
||||
const route = buildProjectSettingsRoute(projectKey);
|
||||
const segment = route.slice("/settings/projects/".length);
|
||||
expect(decodeURIComponent(segment)).toBe(projectKey);
|
||||
it("keeps route ids opaque", () => {
|
||||
expect(normalizeProjectSettingsRouteId("project%2F1")).toBe("project%2F1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -566,10 +566,14 @@ export function buildProjectsSettingsRoute() {
|
||||
return "/settings/projects" as const;
|
||||
}
|
||||
|
||||
export function buildProjectSettingsRoute(projectKey: string) {
|
||||
const normalized = trimNonEmpty(projectKey);
|
||||
if (!normalized) {
|
||||
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
|
||||
export function buildProjectSettingsRoute(serverId: string, projectId: string) {
|
||||
if (!serverId.trim() || !projectId.trim()) {
|
||||
throw new Error("buildProjectSettingsRoute requires a serverId and projectId");
|
||||
}
|
||||
return `/settings/projects/${encodeSegment(normalized)}` as const;
|
||||
return `/settings/projects/${encodeSegment(serverId)}/${encodeSegment(projectId)}` as const;
|
||||
}
|
||||
|
||||
export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string {
|
||||
const id = Array.isArray(value) ? value[0] : value;
|
||||
return typeof id === "string" ? id : "";
|
||||
}
|
||||
|
||||
@@ -1,595 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProjectPlacementPayload } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjects } from "./projects";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjects, getProjectSummaryForHostProject } from "./projects";
|
||||
|
||||
function placement(input: {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
mainRepoRoot?: string | null;
|
||||
}): ProjectPlacementPayload {
|
||||
function descriptor(id: string, key: string, root: string): ProjectDescriptor {
|
||||
return {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName,
|
||||
checkout: {
|
||||
cwd: input.cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
worktreeRoot: input.cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: input.mainRepoRoot ?? null,
|
||||
},
|
||||
projectId: id,
|
||||
projectKey: key,
|
||||
projectDisplayName: "acme/app",
|
||||
projectCustomName: null,
|
||||
projectRootPath: root,
|
||||
projectKind: "git",
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
repoRoot: string;
|
||||
project?: ProjectPlacementPayload;
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
remoteUrl?: string | null;
|
||||
currentBranch?: string;
|
||||
archivingAt?: string | null;
|
||||
}): WorkspaceDescriptor {
|
||||
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
|
||||
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
|
||||
projectRootPath: input.repoRoot,
|
||||
workspaceDirectory: input.repoRoot,
|
||||
id,
|
||||
projectId,
|
||||
projectDisplayName: "acme/app",
|
||||
projectCustomName: null,
|
||||
projectRootPath: root,
|
||||
workspaceDirectory: root,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
name: "main",
|
||||
status: "done",
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: {
|
||||
currentBranch: input.currentBranch ?? "main",
|
||||
remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
},
|
||||
githubRuntime: null,
|
||||
project: input.project,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildProjects", () => {
|
||||
it("normalizes detached and blank branches out of workspace summaries", () => {
|
||||
test("uses the grouped project list while retaining each host project id", () => {
|
||||
const key = "remote:github.com/acme/app";
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
serverId: "host-a",
|
||||
serverName: "Host A",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({ id: "detached", repoRoot: "/repo/app", currentBranch: "HEAD" }),
|
||||
workspace({ id: "blank", repoRoot: "/repo/app", currentBranch: " " }),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
|
||||
{ id: "detached", currentBranch: null },
|
||||
{ id: "blank", currentBranch: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries active archive state into workspace summaries", () => {
|
||||
const archivingAt = "2026-07-15T18:00:00.000Z";
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({ id: "archiving", repoRoot: "/repo/app", archivingAt }),
|
||||
workspace({ id: "active", repoRoot: "/repo/app" }),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
|
||||
{ id: "archiving", archivingAt },
|
||||
{ id: "active" },
|
||||
]);
|
||||
expect(result.projects[0]?.hosts[0]?.workspaces[1]).not.toHaveProperty("archivingAt");
|
||||
});
|
||||
|
||||
it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "feature-a",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app/feature-a",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "feature-b",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app/feature-b",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
projects: [descriptor("prj_a", key, "/a/app")],
|
||||
workspaces: [workspace("ws-a", "prj_a", "/a/app")],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
repoRoot: "/work/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/work/app",
|
||||
remoteUrl: "git@github.com:acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "feature",
|
||||
repoRoot: "/work/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/work/app/feature",
|
||||
remoteUrl: "git@github.com:acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
const summary = result.projects[0];
|
||||
expect(summary?.projectKey).toBe("remote:github.com/acme/app");
|
||||
expect(summary?.projectName).toBe("acme/app");
|
||||
expect(summary?.hostCount).toBe(2);
|
||||
expect(summary?.onlineHostCount).toBe(2);
|
||||
expect(summary?.totalWorkspaceCount).toBe(5);
|
||||
expect(summary?.githubUrl).toBe("https://github.com/acme/app");
|
||||
expect(summary?.hosts).toHaveLength(2);
|
||||
const local = summary?.hosts.find((host) => host.serverId === "local");
|
||||
const laptop = summary?.hosts.find((host) => host.serverId === "laptop");
|
||||
expect(local?.workspaceCount).toBe(3);
|
||||
expect(laptop?.workspaceCount).toBe(2);
|
||||
expect(local?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature-a", "feature-b"]);
|
||||
expect(laptop?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature"]);
|
||||
});
|
||||
|
||||
it("collapses five workspaces on one host into a single host entry whose workspaceCount is five", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: Array.from({ length: 5 }, (_, index) =>
|
||||
workspace({
|
||||
id: `ws-${index}`,
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: `/repo/app/ws-${index}`,
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(5);
|
||||
expect(result.projects[0]?.totalWorkspaceCount).toBe(5);
|
||||
expect(result.projects[0]?.hostCount).toBe(1);
|
||||
});
|
||||
|
||||
it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
repoRoot: "/worktrees/app/main",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/worktrees/app/main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
mainRepoRoot: "/repo/app",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "legacy",
|
||||
serverName: "Legacy",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "legacy",
|
||||
repoRoot: "/repo/legacy",
|
||||
projectId: "legacy-project",
|
||||
projectName: "Legacy",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const acme = result.projects.find(
|
||||
(project) => project.projectKey === "remote:github.com/acme/app",
|
||||
);
|
||||
const legacy = result.projects.find((project) => project.projectKey === "legacy-project");
|
||||
|
||||
expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app");
|
||||
expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy");
|
||||
});
|
||||
|
||||
it("derives githubUrl only when projectKey matches remote:github.com/{owner}/{repo}", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "github",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "local",
|
||||
repoRoot: "/repo/local",
|
||||
project: placement({
|
||||
projectKey: "/repo/local",
|
||||
projectName: "local",
|
||||
cwd: "/repo/local",
|
||||
remoteUrl: null,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const github = result.projects.find(
|
||||
(project) => project.projectKey === "remote:github.com/acme/app",
|
||||
);
|
||||
const local = result.projects.find((project) => project.projectKey === "/repo/local");
|
||||
|
||||
expect(github?.githubUrl).toBe("https://github.com/acme/app");
|
||||
expect(local?.githubUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("totals hostCount across all hosts and counts only online ones in onlineHostCount", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "online",
|
||||
serverName: "Online",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "ws",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "offline",
|
||||
serverName: "Offline",
|
||||
serverId: "host-b",
|
||||
serverName: "Host B",
|
||||
isOnline: false,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "ws",
|
||||
repoRoot: "/repo/app",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
projects: [descriptor("prj_b", key, "/b/app")],
|
||||
workspaces: [workspace("ws-b", "prj_b", "/b/app")],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.hostCount).toBe(2);
|
||||
expect(result.projects[0]?.onlineHostCount).toBe(1);
|
||||
expect(result.projects[0]?.hosts.find((host) => host.serverId === "online")?.isOnline).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.projects[0]?.hosts.find((host) => host.serverId === "offline")?.isOnline).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not merge fallback repo-root-keyed projects with different roots", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "one",
|
||||
repoRoot: "/repo/one",
|
||||
project: placement({
|
||||
projectKey: "/repo/one",
|
||||
projectName: "one",
|
||||
cwd: "/repo/one",
|
||||
remoteUrl: null,
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "two",
|
||||
repoRoot: "/repo/two",
|
||||
project: placement({
|
||||
projectKey: "/repo/two",
|
||||
projectName: "two",
|
||||
cwd: "/repo/two",
|
||||
remoteUrl: null,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects.map((project) => project.projectKey)).toEqual([
|
||||
"/repo/one",
|
||||
"/repo/two",
|
||||
expect(result.projects).toEqual([
|
||||
expect.objectContaining({
|
||||
projectKey: key,
|
||||
hostCount: 2,
|
||||
onlineHostCount: 1,
|
||||
totalWorkspaceCount: 2,
|
||||
hosts: [
|
||||
expect.objectContaining({ serverId: "host-a", projectId: "prj_a" }),
|
||||
expect.objectContaining({ serverId: "host-b", projectId: "prj_b" }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes GitHub, GitLab, Bitbucket and local projects together, sorted by name", () => {
|
||||
test("includes a project with no workspaces", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "github",
|
||||
repoRoot: "/repo/github",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/web",
|
||||
projectName: "acme/web",
|
||||
cwd: "/repo/github",
|
||||
remoteUrl: "https://github.com/acme/web.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "gitlab",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/api",
|
||||
projectName: "acme/api",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/api.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "bitbucket",
|
||||
repoRoot: "/repo/bitbucket",
|
||||
project: placement({
|
||||
projectKey: "remote:bitbucket.org/acme/cli",
|
||||
projectName: "acme/cli",
|
||||
cwd: "/repo/bitbucket",
|
||||
remoteUrl: "https://bitbucket.org/acme/cli.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "local",
|
||||
repoRoot: "/repo/local",
|
||||
project: placement({
|
||||
projectKey: "/repo/local",
|
||||
projectName: "local",
|
||||
cwd: "/repo/local",
|
||||
remoteUrl: null,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects.map((project) => project.projectKey)).toEqual([
|
||||
"remote:gitlab.com/acme/api",
|
||||
"remote:bitbucket.org/acme/cli",
|
||||
"remote:github.com/acme/web",
|
||||
"/repo/local",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a non-GitHub remote project on its own when no other projects are present", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "gitlab",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.projectKey).toBe("remote:gitlab.com/acme/app");
|
||||
expect(result.projects[0]?.projectName).toBe("acme/app");
|
||||
expect(result.projects[0]?.githubUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("groups two workspaces sharing a non-GitHub remote into one project with workspaceCount two", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "feature",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/gitlab/feature",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "old-daemon",
|
||||
serverName: "Old daemon",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "legacy",
|
||||
repoRoot: "/repo/legacy",
|
||||
projectId: "legacy-project",
|
||||
projectName: "Legacy",
|
||||
remoteUrl: "https://gitlab.com/acme/legacy.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
const summary = result.projects[0];
|
||||
expect(summary?.projectKey).toBe("legacy-project");
|
||||
expect(summary?.projectName).toBe("Legacy");
|
||||
expect(summary?.githubUrl).toBeUndefined();
|
||||
expect(summary?.hosts).toHaveLength(1);
|
||||
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
|
||||
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
|
||||
});
|
||||
|
||||
it("surfaces a project with no workspaces yet and gives its host an editable repoRoot", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
serverId: "host-a",
|
||||
serverName: "Host A",
|
||||
isOnline: true,
|
||||
projects: [descriptor("prj_a", "local-a", "/a/app")],
|
||||
workspaces: [],
|
||||
emptyProjects: [
|
||||
{
|
||||
projectId: "/repo/fresh",
|
||||
projectDisplayName: "fresh",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo/fresh",
|
||||
projectKind: "git",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
const summary = result.projects[0];
|
||||
expect(summary?.projectKey).toBe("/repo/fresh");
|
||||
expect(summary?.totalWorkspaceCount).toBe(0);
|
||||
expect(summary?.hosts).toHaveLength(1);
|
||||
// repoRoot must be non-empty or the project settings screen treats the host
|
||||
// as non-editable and shows its empty state.
|
||||
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/fresh");
|
||||
expect(summary?.hosts[0]?.workspaceCount).toBe(0);
|
||||
expect(result.projects[0]).toMatchObject({
|
||||
totalWorkspaceCount: 0,
|
||||
hosts: [{ projectId: "prj_a", repoRoot: "/a/app" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("looks up a grouped project by host-local identity", () => {
|
||||
const project = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
serverName: "Host A",
|
||||
isOnline: true,
|
||||
projects: [descriptor("prj_a", "shared", "/a/app")],
|
||||
workspaces: [],
|
||||
},
|
||||
],
|
||||
}).projects[0];
|
||||
|
||||
expect(getProjectSummaryForHostProject(project ? [project] : [], "host-a", "prj_a")).toBe(
|
||||
project,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
|
||||
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { HostProjectListItem } from "@/projects/host-project-model";
|
||||
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
@@ -14,6 +14,9 @@ export interface WorkspaceSummary {
|
||||
|
||||
export interface ProjectHostEntry {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectCustomName: string | null;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
repoRoot: string;
|
||||
@@ -39,8 +42,7 @@ export interface ProjectHost {
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
/** Project parents with no active workspaces, so they still surface as projects. */
|
||||
emptyProjects?: EmptyProjectDescriptor[];
|
||||
projects: ProjectDescriptor[];
|
||||
}
|
||||
|
||||
export interface BuildProjectsInput {
|
||||
@@ -51,10 +53,35 @@ export interface BuildProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
}
|
||||
|
||||
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
|
||||
export function getProjectSummaryForHostProject(
|
||||
projects: readonly ProjectSummary[],
|
||||
serverId: string,
|
||||
projectId: string,
|
||||
): ProjectSummary | undefined {
|
||||
for (const project of projects) {
|
||||
for (const host of project.hosts) {
|
||||
if (host.serverId === serverId && host.projectId === projectId) return project;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getProjectHostEntry(
|
||||
project: ProjectSummary | undefined,
|
||||
serverId: string,
|
||||
projectId?: string,
|
||||
): ProjectHostEntry | undefined {
|
||||
for (const host of project?.hosts ?? []) {
|
||||
if (host.serverId === serverId && (!projectId || host.projectId === projectId)) return host;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface HostGroup {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectCustomName: string | null;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
@@ -70,50 +97,32 @@ interface ProjectGroup {
|
||||
hostsByServerId: Map<string, HostGroup>;
|
||||
}
|
||||
|
||||
function findProjectCustomName(
|
||||
workspaces: WorkspaceDescriptor[],
|
||||
function findProjectMetadata(
|
||||
host: ProjectHost,
|
||||
projectKey: string,
|
||||
): { customName: string; displayName: string } | null {
|
||||
for (const workspace of workspaces) {
|
||||
if (workspace.projectId === projectKey && workspace.projectCustomName) {
|
||||
): { customName: string | null; displayName: string } | null {
|
||||
for (const project of host.projects) {
|
||||
if (project.projectId === projectKey) {
|
||||
return {
|
||||
customName: workspace.projectCustomName,
|
||||
displayName: workspace.projectDisplayName,
|
||||
customName: project.projectCustomName ?? null,
|
||||
displayName: project.projectDisplayName,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
|
||||
return buildHostProjectList({
|
||||
projects: buildWorkspaceStructureProjects({
|
||||
sessions: [
|
||||
{
|
||||
serverId: host.serverId,
|
||||
workspaces: host.workspaces,
|
||||
emptyProjects: host.emptyProjects,
|
||||
},
|
||||
],
|
||||
}),
|
||||
function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
|
||||
return buildWorkspaceStructureProjects({
|
||||
sessions: hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
projects: host.projects,
|
||||
workspaces: host.workspaces,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function deriveGithubUrl(projectKey: string): string | undefined {
|
||||
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return `https://github.com/${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
function resolveHostRepoRoot(group: HostGroup): string {
|
||||
for (const workspace of group.workspaces) {
|
||||
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
|
||||
if (mainRepoRoot) {
|
||||
return mainRepoRoot;
|
||||
}
|
||||
}
|
||||
return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
|
||||
}
|
||||
|
||||
@@ -137,6 +146,9 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
|
||||
group.workspaces[0];
|
||||
return {
|
||||
serverId: group.serverId,
|
||||
projectId: group.projectId,
|
||||
projectName: group.projectName,
|
||||
projectCustomName: group.projectCustomName,
|
||||
serverName: group.serverName,
|
||||
isOnline: group.isOnline,
|
||||
repoRoot,
|
||||
@@ -167,53 +179,81 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
|
||||
totalWorkspaceCount,
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: deriveGithubUrl(draft.projectKey),
|
||||
};
|
||||
}
|
||||
|
||||
function addHostProjects(
|
||||
groups: Map<string, ProjectGroup>,
|
||||
host: ProjectHost,
|
||||
hostProjects: HostProjectListItem[],
|
||||
): void {
|
||||
const repoRootByProjectId = new Map(
|
||||
host.projects.map((project) => [project.projectId, project.projectRootPath]),
|
||||
);
|
||||
|
||||
for (const hostProject of hostProjects) {
|
||||
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
|
||||
if (!placement) continue;
|
||||
const projectId = placement.projectId;
|
||||
const customName = findProjectMetadata(host, projectId);
|
||||
let group = groups.get(hostProject.projectKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
projectKey: hostProject.projectKey,
|
||||
projectName: customName?.displayName ?? hostProject.projectName,
|
||||
projectCustomName: customName?.customName ?? null,
|
||||
hostsByServerId: new Map(),
|
||||
};
|
||||
groups.set(hostProject.projectKey, group);
|
||||
} else if (customName?.customName && !group.projectCustomName) {
|
||||
group.projectCustomName = customName.customName;
|
||||
group.projectName = customName.displayName;
|
||||
}
|
||||
|
||||
if (!group.hostsByServerId.has(host.serverId)) {
|
||||
group.hostsByServerId.set(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
projectId,
|
||||
projectName: customName?.displayName ?? hostProject.projectName,
|
||||
projectCustomName: customName?.customName ?? null,
|
||||
serverName: host.serverName,
|
||||
isOnline: host.isOnline,
|
||||
workspaces: [],
|
||||
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attachHostWorkspaces(
|
||||
groups: Map<string, ProjectGroup>,
|
||||
host: ProjectHost,
|
||||
hostProjects: HostProjectListItem[],
|
||||
): void {
|
||||
const projectKeyByProjectId = new Map(
|
||||
hostProjects.flatMap((project) =>
|
||||
project.hosts
|
||||
.filter((placement) => placement.serverId === host.serverId && placement.projectId)
|
||||
.map((placement) => [placement.projectId!, project.projectKey] as const),
|
||||
),
|
||||
);
|
||||
|
||||
for (const workspace of host.workspaces) {
|
||||
const key = projectKeyByProjectId.get(workspace.projectId);
|
||||
if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
const groups = new Map<string, ProjectGroup>();
|
||||
const projectEntries = buildHostProjectEntries(input.hosts);
|
||||
|
||||
for (const host of input.hosts) {
|
||||
const emptyRepoRootByProjectKey = new Map<string, string>();
|
||||
for (const emptyProject of host.emptyProjects ?? []) {
|
||||
emptyRepoRootByProjectKey.set(emptyProject.projectId, emptyProject.projectRootPath);
|
||||
}
|
||||
|
||||
const hostProjects = buildHostProjectEntries(host);
|
||||
for (const hostProject of hostProjects) {
|
||||
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
|
||||
let group = groups.get(hostProject.projectKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
projectKey: hostProject.projectKey,
|
||||
projectName: customName?.displayName ?? hostProject.projectName,
|
||||
projectCustomName: customName?.customName ?? null,
|
||||
hostsByServerId: new Map(),
|
||||
};
|
||||
groups.set(hostProject.projectKey, group);
|
||||
} else if (customName && !group.projectCustomName) {
|
||||
group.projectCustomName = customName.customName;
|
||||
group.projectName = customName.displayName;
|
||||
}
|
||||
|
||||
if (!group.hostsByServerId.has(host.serverId)) {
|
||||
group.hostsByServerId.set(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline: host.isOnline,
|
||||
workspaces: [],
|
||||
fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspace of host.workspaces) {
|
||||
const group = groups.get(workspace.projectId);
|
||||
const hostGroup = group?.hostsByServerId.get(host.serverId);
|
||||
if (!hostGroup) continue;
|
||||
hostGroup.workspaces.push(workspace);
|
||||
}
|
||||
const hostProjects = projectEntries.filter((project) =>
|
||||
project.hosts.some((placement) => placement.serverId === host.serverId),
|
||||
);
|
||||
addHostProjects(groups, host, hostProjects);
|
||||
attachHostWorkspaces(groups, host, hostProjects);
|
||||
}
|
||||
|
||||
const projects = Array.from(groups.values()).map(toProjectSummary);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import {
|
||||
buildSidebarProjectRowModel,
|
||||
resolveSidebarProjectIconTarget,
|
||||
resolveSidebarProjectLocalPath,
|
||||
} from "./sidebar-project-row-model";
|
||||
|
||||
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
|
||||
@@ -34,18 +35,26 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
};
|
||||
}
|
||||
|
||||
function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry {
|
||||
type ProjectOverrides = Omit<Partial<SidebarProjectEntry>, "hosts"> & {
|
||||
hosts?: Array<Omit<SidebarProjectEntry["hosts"][number], "projectId"> & { projectId?: string }>;
|
||||
};
|
||||
|
||||
function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
|
||||
const projectKind = overrides.projectKind ?? "git";
|
||||
const hosts = Array.from(
|
||||
overrides.hosts ?? [
|
||||
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
|
||||
],
|
||||
(host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }),
|
||||
);
|
||||
return {
|
||||
projectKey: "project-1",
|
||||
projectName: "paseo",
|
||||
projectKind,
|
||||
iconWorkingDir: "/repo",
|
||||
hosts: overrides.hosts ?? [
|
||||
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
|
||||
],
|
||||
workspaces: [workspace()],
|
||||
...overrides,
|
||||
hosts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,7 +89,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
chevron: "expand",
|
||||
trailingAction: {
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -94,7 +103,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
|
||||
expect(result.trailingAction).toEqual({
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,7 +126,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
|
||||
expect(result.trailingAction).toEqual({
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +187,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
chevron: "expand",
|
||||
trailingAction: {
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -193,7 +202,24 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(iconTarget).toEqual({ serverId: "host-b", iconWorkingDir: "/repo/b" });
|
||||
expect(iconTarget).toEqual({
|
||||
serverId: "host-b",
|
||||
projectId: "project-host-b",
|
||||
iconWorkingDir: "/repo/b",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves desktop file actions from the local project placement", () => {
|
||||
const groupedProject = project({
|
||||
iconWorkingDir: "/remote/repo",
|
||||
hosts: [
|
||||
{ serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true },
|
||||
{ serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(resolveSidebarProjectLocalPath(groupedProject, "local")).toBe("/local/repo");
|
||||
expect(resolveSidebarProjectLocalPath(groupedProject, "missing")).toBe("");
|
||||
});
|
||||
|
||||
it("renders an empty project as an expandable section", () => {
|
||||
@@ -207,7 +233,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
chevron: "collapse",
|
||||
trailingAction: {
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
export interface SidebarProjectHostTarget {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
iconWorkingDir: string;
|
||||
}
|
||||
|
||||
@@ -18,16 +19,16 @@ export interface SidebarProjectSectionRowModel {
|
||||
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
|
||||
|
||||
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
|
||||
|
||||
function hostTarget(input: {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
iconWorkingDir: string;
|
||||
}): SidebarProjectHostTarget | null {
|
||||
const iconWorkingDir = input.iconWorkingDir.trim();
|
||||
if (!input.serverId || !iconWorkingDir) {
|
||||
return null;
|
||||
}
|
||||
return { serverId: input.serverId, iconWorkingDir };
|
||||
return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir };
|
||||
}
|
||||
|
||||
export function resolveSidebarProjectIconTarget(
|
||||
@@ -42,6 +43,14 @@ export function resolveSidebarProjectIconTarget(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveSidebarProjectLocalPath(
|
||||
project: SidebarProjectEntry,
|
||||
localServerId: string | null,
|
||||
): string {
|
||||
if (!localServerId) return "";
|
||||
return project.hosts.find((host) => host.serverId === localServerId)?.iconWorkingDir.trim() ?? "";
|
||||
}
|
||||
|
||||
// A project can host a brand-new workspace on a host when that host can create a
|
||||
// git worktree (git projects) OR the host supports running multiple independent
|
||||
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
|
||||
@@ -57,9 +66,7 @@ function resolveNewWorkspaceTarget(
|
||||
continue;
|
||||
}
|
||||
const target = hostTarget(host);
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
if (target) return target;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
||||
hosts: [
|
||||
{
|
||||
serverId: workspaces[0]?.serverId ?? "s1",
|
||||
projectId: projectKey,
|
||||
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility";
|
||||
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
|
||||
import {
|
||||
applyLegacyDaemonWorkspaceOwnership,
|
||||
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
|
||||
@@ -10,6 +11,17 @@ import {
|
||||
|
||||
const SERVER_ID = "srv_legacy";
|
||||
|
||||
function legacyProjectFromWorkspace(workspace: WorkspaceDescriptor) {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
projectKey: null,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyAgent(input: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
@@ -118,6 +130,38 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps matching legacy path projects separate across hosts", () => {
|
||||
const first = buildLegacyDaemonWorkspaceSnapshot({
|
||||
serverId: "host-a",
|
||||
entries: [legacyAgent({ id: "agent-a", cwd: "/repo/app" })],
|
||||
});
|
||||
const second = buildLegacyDaemonWorkspaceSnapshot({
|
||||
serverId: "host-b",
|
||||
entries: [legacyAgent({ id: "agent-b", cwd: "/repo/app" })],
|
||||
});
|
||||
|
||||
const projects = buildWorkspaceStructureProjects({
|
||||
sessions: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
projects: Array.from(first.workspaces.values(), legacyProjectFromWorkspace),
|
||||
workspaces: first.workspaces.values(),
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
projects: Array.from(second.workspaces.values(), legacyProjectFromWorkspace),
|
||||
workspaces: second.workspaces.values(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(projects).toHaveLength(2);
|
||||
expect(projects.map((project) => project.hosts[0]?.serverId).sort()).toEqual([
|
||||
"host-a",
|
||||
"host-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps old-daemon agent updates attached to the path-backed workspace", () => {
|
||||
const snapshot = buildLegacyDaemonWorkspaceSnapshot({
|
||||
serverId: SERVER_ID,
|
||||
@@ -185,7 +229,7 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => {
|
||||
client,
|
||||
serverId: SERVER_ID,
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
projects: new Map(),
|
||||
isCancelled: () => cancelled,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
deriveAgentStateBucket,
|
||||
getWorkspaceStateBucketPriority,
|
||||
} from "@getpaseo/protocol/agent-state-bucket";
|
||||
import type { Agent, DaemonServerInfo, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type {
|
||||
Agent,
|
||||
DaemonServerInfo,
|
||||
ProjectDescriptor,
|
||||
WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildAgentDirectoryState,
|
||||
@@ -33,7 +38,7 @@ interface LegacyDaemonWorkspaceBackfillInput {
|
||||
client: Pick<DaemonClient, "fetchAgents">;
|
||||
serverId: string;
|
||||
workspaces: ReadonlyMap<unknown, unknown>;
|
||||
emptyProjects: ReadonlyMap<unknown, unknown>;
|
||||
projects: ReadonlyMap<unknown, unknown>;
|
||||
isCancelled?: () => boolean;
|
||||
}
|
||||
|
||||
@@ -97,7 +102,7 @@ export async function fetchLegacyDaemonWorkspaceDirectory(input: {
|
||||
export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty(
|
||||
input: LegacyDaemonWorkspaceBackfillInput,
|
||||
): Promise<boolean> {
|
||||
if (input.workspaces.size > 0 || input.emptyProjects.size > 0) {
|
||||
if (input.workspaces.size > 0 || input.projects.size > 0) {
|
||||
return false;
|
||||
}
|
||||
const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo;
|
||||
@@ -205,11 +210,25 @@ export function replaceLegacyDaemonWorkspaceDirectory(input: {
|
||||
const workspaces = buildLegacyWorkspaces(entries);
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(input.serverId, workspaces);
|
||||
store.setEmptyProjects(input.serverId, []);
|
||||
store.setProjects(
|
||||
input.serverId,
|
||||
Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace),
|
||||
);
|
||||
store.setHasHydratedWorkspaces(input.serverId, true);
|
||||
return { agents, workspaces };
|
||||
}
|
||||
|
||||
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
projectKey: null,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
projectKind: workspace.projectKind,
|
||||
};
|
||||
}
|
||||
|
||||
function readFetchAgentsHasMore(
|
||||
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
|
||||
): boolean {
|
||||
|
||||
@@ -678,6 +678,10 @@ export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, "type" | "requ
|
||||
};
|
||||
export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number];
|
||||
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload["pageInfo"];
|
||||
export type ProjectListPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "project.list.response" }
|
||||
>["payload"];
|
||||
export interface CreateChatRoomOptions {
|
||||
name: string;
|
||||
purpose?: string | null;
|
||||
@@ -2092,6 +2096,24 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listProjects(requestId?: string): Promise<ProjectListPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "project.list.request",
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "project.list.response") return null;
|
||||
if (msg.payload.requestId !== resolvedRequestId) return null;
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
|
||||
@@ -48,4 +48,19 @@ describe("parseGitRemoteLocation port", () => {
|
||||
it("has no port for an scp-form remote", () => {
|
||||
expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits an explicitly written default port", () => {
|
||||
expect(parseGitRemoteLocation("ssh://git@github.com:22/acme/repo.git")).toEqual(
|
||||
parseGitRemoteLocation("ssh://git@github.com/acme/repo.git"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a scheme remote as a URL rather than scp form", () => {
|
||||
expect(parseGitRemoteLocation("ssh://git@example.com:2222/acme/repo.git")).toEqual({
|
||||
transport: "ssh",
|
||||
host: "example.com",
|
||||
port: "2222",
|
||||
path: "acme/repo",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,12 @@ const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
|
||||
"ssh:": "ssh",
|
||||
};
|
||||
|
||||
const DEFAULT_PORT_BY_PROTOCOL: Record<string, string> = {
|
||||
"https:": "443",
|
||||
"http:": "80",
|
||||
"ssh:": "22",
|
||||
};
|
||||
|
||||
export interface GitRemoteLocation {
|
||||
transport: "scp" | "ssh" | "http" | "https";
|
||||
host: string;
|
||||
@@ -50,7 +56,9 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
|
||||
// scp form has no scheme. Testing it first would match `ssh://git@host:22/x`
|
||||
// — `[^@]+` happily eats `ssh://git` — and swallow the port into the path.
|
||||
const scpLike = trimmed.includes("://") ? null : trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
|
||||
if (scpLike) {
|
||||
const host = normalizeHost(scpLike[1] ?? "");
|
||||
const path = normalizeRemotePath(scpLike[2] ?? "");
|
||||
@@ -78,7 +86,10 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n
|
||||
const normalizedPath = normalizeRemotePath(path);
|
||||
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
||||
|
||||
return { transport, host, port: parsed.port || undefined, path: normalizedPath };
|
||||
const protocol = parsed.protocol.toLowerCase();
|
||||
const port =
|
||||
parsed.port && parsed.port !== DEFAULT_PORT_BY_PROTOCOL[protocol] ? parsed.port : undefined;
|
||||
return { transport, host, port, path: normalizedPath };
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
||||
|
||||
@@ -1072,6 +1072,11 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ProjectListRequestMessageSchema = z.object({
|
||||
type: z.literal("project.list.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const FetchAgentHistoryRequestMessageSchema = z.object({
|
||||
type: z.literal("fetch_agent_history_request"),
|
||||
requestId: z.string(),
|
||||
@@ -2445,6 +2450,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchAgentHistoryRequestMessageSchema,
|
||||
FetchRecentProviderSessionsRequestMessageSchema,
|
||||
FetchWorkspacesRequestMessageSchema,
|
||||
ProjectListRequestMessageSchema,
|
||||
FetchAgentRequestMessageSchema,
|
||||
DeleteAgentRequestMessageSchema,
|
||||
ArchiveAgentRequestMessageSchema,
|
||||
@@ -2814,6 +2820,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceGithubRepositorySearch: z.boolean().optional(),
|
||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||
projectCreateDirectory: z.boolean().optional(),
|
||||
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
|
||||
projectList: z.boolean().optional(),
|
||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||
commitsList: z.boolean().optional(),
|
||||
// COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23.
|
||||
@@ -3232,6 +3240,8 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
|
||||
// workspace is archived.
|
||||
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
|
||||
projectId: z.string(),
|
||||
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
|
||||
projectKey: z.string().optional(),
|
||||
projectDisplayName: z.string(),
|
||||
projectCustomName: z.string().nullable().optional(),
|
||||
projectRootPath: z.string(),
|
||||
@@ -3288,6 +3298,14 @@ export const ProjectUpdateMessageSchema = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
export const ProjectListResponseMessageSchema = z.object({
|
||||
type: z.literal("project.list.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
projects: z.array(WorkspaceProjectDescriptorPayloadSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ScriptStatusUpdateMessageSchema = z.object({
|
||||
type: z.literal("script_status_update"),
|
||||
payload: z.object({
|
||||
@@ -5185,6 +5203,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentUpdateMessageSchema,
|
||||
WorkspaceUpdateMessageSchema,
|
||||
ProjectUpdateMessageSchema,
|
||||
ProjectListResponseMessageSchema,
|
||||
ScriptStatusUpdateMessageSchema,
|
||||
WorkspaceSetupProgressMessageSchema,
|
||||
WorkspaceSetupStatusResponseMessageSchema,
|
||||
@@ -5363,6 +5382,7 @@ export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPaylo
|
||||
export type WorkspaceProjectDescriptorPayload = z.infer<
|
||||
typeof WorkspaceProjectDescriptorPayloadSchema
|
||||
>;
|
||||
export type ProjectListResponseMessage = z.infer<typeof ProjectListResponseMessageSchema>;
|
||||
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
|
||||
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
|
||||
export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
|
||||
@@ -5508,6 +5528,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
|
||||
typeof FetchRecentProviderSessionsRequestMessageSchema
|
||||
>;
|
||||
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
||||
export type ProjectListRequestMessage = z.infer<typeof ProjectListRequestMessageSchema>;
|
||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
|
||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||
|
||||
@@ -2564,6 +2564,7 @@ describe("create_agent MCP tool", () => {
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
projectRegistry: {
|
||||
get: async (projectId) => (projectId === project.projectId ? project : null),
|
||||
list: async () => [project],
|
||||
},
|
||||
createPaseoWorktree,
|
||||
logger,
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface PaseoToolHostDependencies {
|
||||
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
|
||||
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
||||
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||
projectRegistry?: Pick<ProjectRegistry, "get">;
|
||||
projectRegistry?: Pick<ProjectRegistry, "get" | "list">;
|
||||
createDirectoryWorkspace?: (
|
||||
cwd: string,
|
||||
title?: string | null,
|
||||
|
||||
@@ -793,6 +793,7 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
});
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
serverId,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
@@ -829,6 +830,7 @@ export async function createPaseoDaemon(
|
||||
await agentStorage.initialize();
|
||||
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
|
||||
await bootstrapWorkspaceRegistries({
|
||||
serverId,
|
||||
paseoHome: config.paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
@@ -842,6 +844,7 @@ export async function createPaseoDaemon(
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
};
|
||||
const workspaceReconciliation = new WorkspaceReconciliationService({
|
||||
serverId,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
@@ -857,7 +860,7 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
});
|
||||
await workspaceReconciliation.start();
|
||||
void workspaceReconciliation.runOnce().catch((error) => {
|
||||
void workspaceReconciliation.reconcileNow().catch((error) => {
|
||||
logger.warn({ err: error }, "Initial workspace reconciliation failed");
|
||||
});
|
||||
await chatService.initialize();
|
||||
|
||||
@@ -23,6 +23,7 @@ import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
import { deriveProjectKey } from "./project-key.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
@@ -73,7 +74,16 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
expect(result.workspace.baseBranch).toBe("main");
|
||||
expect(result.workspace.title).toBe("Feature One");
|
||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
|
||||
expect(deps.projects.get(sourceProject.projectId)).toEqual({
|
||||
...sourceProject,
|
||||
projectKey: deriveProjectKey({
|
||||
rootPath: result.repoRoot,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
|
||||
});
|
||||
|
||||
@@ -436,7 +446,16 @@ test("an explicit project FK remains unchanged when its worktree comes from anot
|
||||
);
|
||||
|
||||
expect(result.workspace.projectId).toBe(project.projectId);
|
||||
expect(deps.projects.get(project.projectId)).toEqual(project);
|
||||
expect(deps.projects.get(project.projectId)).toEqual({
|
||||
...project,
|
||||
projectKey: deriveProjectKey({
|
||||
rootPath: project.rootPath,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { deriveProjectGroupingDisplayName, deriveProjectKey } from "./project-key.js";
|
||||
|
||||
describe("deriveProjectKey", () => {
|
||||
const rootPath = path.resolve("repo");
|
||||
const derive = (remoteUrl: string) =>
|
||||
deriveProjectKey({ rootPath, remoteUrl, worktreeRoot: rootPath, mainRepoRoot: null });
|
||||
|
||||
test.each([
|
||||
"https://github.com/getpaseo/paseo.git",
|
||||
"ssh://git@github.com/getpaseo/paseo.git",
|
||||
"git@github.com:getpaseo/paseo.git",
|
||||
])("normalizes common remote form %s", (remoteUrl) => {
|
||||
expect(derive(remoteUrl)).toBe("remote:github.com/getpaseo/paseo");
|
||||
});
|
||||
|
||||
test("normalizes GitHub casing", () => {
|
||||
expect(derive("git@github.com:GetPaseo/Paseo.git")).toBe("remote:github.com/getpaseo/paseo");
|
||||
});
|
||||
|
||||
test("preserves self-hosted paths and explicit ports", () => {
|
||||
expect(derive("https://git.example.com:8443/Team/App.git")).toBe(
|
||||
"remote:git.example.com:8443/Team/App",
|
||||
);
|
||||
});
|
||||
|
||||
test("groups an SSH remote whether or not its default port is spelled out", () => {
|
||||
expect(derive("ssh://git@github.com:22/getpaseo/paseo.git")).toBe(
|
||||
derive("ssh://git@github.com/getpaseo/paseo.git"),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps projects without a remote host-local", () => {
|
||||
const hostA = deriveProjectKey({
|
||||
rootPath,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
mainRepoRoot: null,
|
||||
serverId: "host-a",
|
||||
});
|
||||
const hostB = deriveProjectKey({
|
||||
rootPath,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
mainRepoRoot: null,
|
||||
serverId: "host-b",
|
||||
});
|
||||
expect(hostA).not.toBe(hostB);
|
||||
});
|
||||
|
||||
test("uses one host-local key for two spellings of the same directory", () => {
|
||||
const tempRoot = mkdtempSync(path.join(tmpdir(), "project-key-identity-"));
|
||||
const projectRoot = path.join(tempRoot, "project");
|
||||
const alternateRoot = path.join(tempRoot, "project-alias");
|
||||
const deriveLocalProject = (localRootPath: string) =>
|
||||
deriveProjectKey({
|
||||
rootPath: localRootPath,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
mainRepoRoot: null,
|
||||
serverId: "host-a",
|
||||
});
|
||||
|
||||
try {
|
||||
mkdirSync(projectRoot);
|
||||
symlinkSync(projectRoot, alternateRoot, process.platform === "win32" ? "junction" : "dir");
|
||||
expect(deriveLocalProject(alternateRoot)).toBe(deriveLocalProject(projectRoot));
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a repository root distinct from one of its subprojects", () => {
|
||||
const worktreeRoot = path.resolve("host-a", "repo");
|
||||
const remoteUrl = "git@github.com:getpaseo/paseo.git";
|
||||
|
||||
expect(
|
||||
deriveProjectKey({ rootPath: worktreeRoot, remoteUrl, worktreeRoot, mainRepoRoot: null }),
|
||||
).toBe("remote:github.com/getpaseo/paseo");
|
||||
expect(
|
||||
deriveProjectKey({
|
||||
rootPath: path.join(worktreeRoot, "packages", "app"),
|
||||
remoteUrl,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app");
|
||||
});
|
||||
|
||||
test("groups the same subproject across different absolute checkout roots", () => {
|
||||
const remoteUrl = "git@github.com:getpaseo/paseo.git";
|
||||
const deriveSubproject = (worktreeRoot: string) =>
|
||||
deriveProjectKey({
|
||||
rootPath: path.join(worktreeRoot, "packages", "app"),
|
||||
remoteUrl,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
|
||||
expect(deriveSubproject(path.resolve("host-a", "repo"))).toBe(
|
||||
deriveSubproject(path.resolve("host-b", "different", "repo")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveProjectGroupingDisplayName", () => {
|
||||
test("uses the owner and repository from a remote", () => {
|
||||
expect(
|
||||
deriveProjectGroupingDisplayName({
|
||||
rootPath: path.resolve("repo"),
|
||||
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
||||
worktreeRoot: path.resolve("repo"),
|
||||
}),
|
||||
).toBe("getpaseo/paseo");
|
||||
});
|
||||
|
||||
test("uses the selected directory name without a remote", () => {
|
||||
expect(
|
||||
deriveProjectGroupingDisplayName({
|
||||
rootPath: path.resolve("acme", "app"),
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
}),
|
||||
).toBe("app");
|
||||
});
|
||||
|
||||
test("uses the selected subproject directory name instead of the repository remote", () => {
|
||||
const worktreeRoot = path.resolve("acme", "repo");
|
||||
|
||||
expect(
|
||||
deriveProjectGroupingDisplayName({
|
||||
rootPath: path.join(worktreeRoot, "packages", "server"),
|
||||
remoteUrl: "git@github.com:acme/repo.git",
|
||||
worktreeRoot,
|
||||
}),
|
||||
).toBe("server");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { resolve, win32 } from "node:path";
|
||||
import { isGitHubHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import { getRealpathAwareRelativePath, normalizePathForIdentity } from "../utils/path.js";
|
||||
|
||||
/** Persisted opaque key used to join the same remote across hosts. */
|
||||
export function deriveProjectKey(input: {
|
||||
rootPath: string;
|
||||
remoteUrl: string | null;
|
||||
worktreeRoot: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
serverId?: string;
|
||||
}): string {
|
||||
const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null;
|
||||
const selectedPath = input.worktreeRoot
|
||||
? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath) || null
|
||||
: null;
|
||||
if (remote) {
|
||||
const host = remote.port ? `${remote.host}:${remote.port}` : remote.host;
|
||||
const path = isGitHubHost(remote.host) ? remote.path.toLowerCase() : remote.path;
|
||||
const remoteKey = `remote:${host}/${path}`;
|
||||
return selectedPath ? `${remoteKey}#subdir:${selectedPath.replaceAll("\\", "/")}` : remoteKey;
|
||||
}
|
||||
|
||||
const localPathParts = [
|
||||
selectedPath && input.mainRepoRoot ? input.mainRepoRoot : input.rootPath,
|
||||
selectedPath && input.mainRepoRoot ? selectedPath : "",
|
||||
];
|
||||
const resolvedLocalPath = localPathParts.some(looksLikeWindowsPath)
|
||||
? win32.resolve(...localPathParts)
|
||||
: resolve(...localPathParts);
|
||||
const localPath = normalizePathForIdentity(resolvedLocalPath);
|
||||
return input.serverId ? `host:${input.serverId}:${localPath}` : localPath;
|
||||
}
|
||||
|
||||
export function deriveProjectGroupingDisplayName(input: {
|
||||
rootPath: string;
|
||||
remoteUrl: string | null;
|
||||
worktreeRoot: string | null;
|
||||
}): string {
|
||||
const selectedPath = input.worktreeRoot
|
||||
? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath)
|
||||
: null;
|
||||
if (selectedPath) return lastPathSegment(input.rootPath);
|
||||
|
||||
const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null;
|
||||
if (!remote) return lastPathSegment(input.rootPath);
|
||||
return remote.path.split("/").filter(Boolean).slice(-2).join("/") || input.rootPath;
|
||||
}
|
||||
|
||||
function lastPathSegment(inputPath: string): string {
|
||||
const segments = inputPath.split(/[\\/]/u).filter(Boolean);
|
||||
return segments[segments.length - 1] ?? inputPath;
|
||||
}
|
||||
|
||||
function looksLikeWindowsPath(value: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/u.test(value) || /^[/\\]{2}[^/\\]+[/\\][^/\\]+/u.test(value);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { AgentManagerEvent } from "./agent/agent-manager.js";
|
||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
||||
import { deriveProjectKey } from "./project-key.js";
|
||||
import type { SessionOptions } from "./session.js";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
||||
import {
|
||||
@@ -649,6 +650,12 @@ describe("project command-center RPCs", () => {
|
||||
rootPath: directoryPath,
|
||||
kind: "non_git",
|
||||
displayName: "new-project",
|
||||
projectKey: deriveProjectKey({
|
||||
rootPath: directoryPath,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
expect(messages).toEqual([
|
||||
@@ -3416,7 +3423,7 @@ describe("session workspace descriptors", () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspace = {
|
||||
workspaceId: "ws-gh",
|
||||
projectId: "remote:github.com/acme/app",
|
||||
projectId: "prj_app",
|
||||
cwd: "/repo/app",
|
||||
kind: "local_checkout" as const,
|
||||
displayName: "app",
|
||||
@@ -3424,7 +3431,8 @@ describe("session workspace descriptors", () => {
|
||||
archivedAt: null,
|
||||
};
|
||||
const project = {
|
||||
projectId: "remote:github.com/acme/app",
|
||||
projectId: "prj_app",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
rootPath: "/repo/app",
|
||||
kind: "git" as const,
|
||||
displayName: "acme/app",
|
||||
@@ -3435,7 +3443,10 @@ describe("session workspace descriptors", () => {
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([project]),
|
||||
get: vi.fn().mockResolvedValue(project),
|
||||
},
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(() =>
|
||||
@@ -3464,8 +3475,9 @@ describe("session workspace descriptors", () => {
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
id: "ws-gh",
|
||||
projectId: "prj_app",
|
||||
project: expect.objectContaining({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectKey: "prj_app",
|
||||
projectName: "acme/app",
|
||||
workspaceName: "app",
|
||||
checkout: expect.objectContaining({
|
||||
@@ -4900,6 +4912,53 @@ test("sends project updates only to capable sockets in a retained session", () =
|
||||
]);
|
||||
});
|
||||
|
||||
test("project.list returns every active project descriptor", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const active = createPersistedProjectRecord({
|
||||
projectId: "project-active",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
rootPath: "/tmp/project-active",
|
||||
kind: "git",
|
||||
displayName: "acme/app",
|
||||
createdAt: "2026-07-17T00:00:00.000Z",
|
||||
updatedAt: "2026-07-17T00:00:00.000Z",
|
||||
});
|
||||
const archived = createPersistedProjectRecord({
|
||||
projectId: "project-archived",
|
||||
rootPath: "/tmp/project-archived",
|
||||
kind: "non_git",
|
||||
displayName: "archived",
|
||||
createdAt: "2026-07-17T00:00:00.000Z",
|
||||
updatedAt: "2026-07-17T00:00:00.000Z",
|
||||
archivedAt: "2026-07-18T00:00:00.000Z",
|
||||
});
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([active, archived]) },
|
||||
});
|
||||
|
||||
await session.handleMessage({ type: "project.list.request", requestId: "projects-1" });
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "project.list.response",
|
||||
payload: {
|
||||
requestId: "projects-1",
|
||||
projects: [
|
||||
{
|
||||
projectId: "project-active",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectDisplayName: "acme/app",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/tmp/project-active",
|
||||
projectKind: "git",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("agent config setters", () => {
|
||||
function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): {
|
||||
[K in keyof SessionOptions["agentManager"]]?: unknown;
|
||||
|
||||
@@ -739,6 +739,7 @@ export class Session {
|
||||
});
|
||||
this.workspaceAutoName = workspaceAutoName;
|
||||
this.workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
serverId,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
@@ -2073,6 +2074,8 @@ export class Session {
|
||||
switch (msg.type) {
|
||||
case "fetch_workspaces_request":
|
||||
return this.handleFetchWorkspacesRequest(msg);
|
||||
case "project.list.request":
|
||||
return this.handleProjectListRequest(msg.requestId);
|
||||
case "paseo_worktree_list_request":
|
||||
return this.handlePaseoWorktreeListRequest(msg);
|
||||
case "paseo_worktree_archive_request":
|
||||
@@ -2631,7 +2634,7 @@ export class Session {
|
||||
// resolved name lands in the UI immediately.
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const affectedWorkspaceIds = workspaces
|
||||
.filter((workspace) => workspace.projectId === projectId)
|
||||
.filter((workspace) => workspace.projectId === existing.projectId)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
if (affectedWorkspaceIds.length > 0) {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds);
|
||||
@@ -2670,8 +2673,10 @@ export class Session {
|
||||
this.sessionLogger.info({ projectId, requestId }, "session: project.remove.request");
|
||||
|
||||
try {
|
||||
const project = await this.projectRegistry.get(projectId);
|
||||
const resolvedProjectId = project?.projectId ?? projectId;
|
||||
const projectWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === projectId,
|
||||
(workspace) => workspace.projectId === resolvedProjectId,
|
||||
);
|
||||
const activeWorkspaceIds = projectWorkspaces
|
||||
.filter((workspace) => !workspace.archivedAt)
|
||||
@@ -2699,7 +2704,7 @@ export class Session {
|
||||
removedWorkspaceIds.push(workspaceId);
|
||||
}
|
||||
|
||||
await this.projectRegistry.remove(projectId);
|
||||
await this.projectRegistry.remove(resolvedProjectId);
|
||||
} finally {
|
||||
if (activeWorkspaceIds.length > 0) {
|
||||
this.clearWorkspaceArchiving(activeWorkspaceIds);
|
||||
@@ -4460,6 +4465,7 @@ export class Session {
|
||||
): WorkspaceProjectDescriptorPayload {
|
||||
return {
|
||||
projectId: project.projectId,
|
||||
...(project.projectKey ? { projectKey: project.projectKey } : {}),
|
||||
projectDisplayName: resolveProjectDisplayName(project),
|
||||
projectCustomName: project.customName ?? null,
|
||||
projectRootPath: project.rootPath,
|
||||
@@ -4934,6 +4940,29 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProjectListRequest(requestId: string): Promise<void> {
|
||||
try {
|
||||
const projects = (await this.projectRegistry.list())
|
||||
.filter((project) => !project.archivedAt)
|
||||
.map((project) => this.buildProjectDescriptor(project));
|
||||
this.emit({
|
||||
type: "project.list.response",
|
||||
payload: { requestId, projects },
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Failed to handle project.list.request");
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "project.list.request",
|
||||
error: error instanceof Error ? error.message : "Failed to list projects",
|
||||
code: "project_list_failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates`
|
||||
// to decide which pending updates to drop. Captures the status,
|
||||
// statusEnteredAt, and activityAt (parsed to ms) for each workspace entry
|
||||
|
||||
@@ -46,6 +46,7 @@ import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js
|
||||
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
import type { ForgeService } from "../services/forge-service.js";
|
||||
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||
import { deriveProjectKey } from "./project-key.js";
|
||||
import {
|
||||
asSessionLogger,
|
||||
asAgentManager,
|
||||
@@ -819,6 +820,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
clientId: "test-client",
|
||||
serverId: "test-server",
|
||||
scopes: ["*"],
|
||||
appVersion: null,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
@@ -870,10 +872,21 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
|
||||
const [createdAgent] = agentManager.listAgents();
|
||||
expect(createdAgent?.cwd).toBe(child);
|
||||
const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!);
|
||||
expect(createdWorkspace).not.toBeNull();
|
||||
await expect(projectRegistry.get(createdWorkspace!.projectId)).resolves.toMatchObject({
|
||||
projectKey: deriveProjectKey({
|
||||
rootPath: child,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
mainRepoRoot: null,
|
||||
serverId: "test-server",
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!),
|
||||
).resolves.toMatchObject({
|
||||
projectKey: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||
projectKey: createdWorkspace!.projectId,
|
||||
checkout: { cwd: child },
|
||||
});
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
@@ -3405,6 +3418,7 @@ test("project.remove.request archives active workspaces and removes the project
|
||||
});
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-remove-with-workspace",
|
||||
projectKey: "remote:github.com/acme/remove-with-workspace",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
@@ -3710,7 +3724,15 @@ test("create paseo worktree response preserves an explicit non-Git project", asy
|
||||
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123"));
|
||||
expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
|
||||
expect(projects.get(explicitProject.projectId)).toEqual(explicitProject);
|
||||
expect(projects.get(explicitProject.projectId)).toEqual({
|
||||
...explicitProject,
|
||||
projectKey: deriveProjectKey({
|
||||
rootPath: explicitProject.rootPath,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace updates stay scoped to the matching cwd", async () => {
|
||||
@@ -7387,7 +7409,8 @@ test("project.rename.request stores customName and emits an updated workspace de
|
||||
);
|
||||
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
projectId: "prj_rename",
|
||||
projectKey: "remote:github.com/acme/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user