mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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.
This commit is contained in:
@@ -4,9 +4,15 @@
|
||||
|
||||
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
|
||||
|
||||
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
||||
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
|
||||
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
|
||||
`projectGroupKey` is a persisted, opaque equivalence key used only to group the same logical project
|
||||
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
|
||||
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
|
||||
Creation persists it with the project, and normal boot reconciliation fills or refreshes it for
|
||||
older records where the field is absent—there is no migration.
|
||||
|
||||
`kind` and `projectGroupKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
||||
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
|
||||
names, and workspace foreign keys. Attached workspaces are independently refreshed
|
||||
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
|
||||
are observed too.
|
||||
|
||||
@@ -448,16 +454,17 @@ Single file containing an array of all loop records. Writes are direct (not atom
|
||||
|
||||
Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
||||
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
||||
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
||||
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
| Field | Type | Description |
|
||||
| ----------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||
| `projectGroupKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
|
||||
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
||||
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
||||
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
||||
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
|
||||
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its separate persisted `projectGroupKey` is an opaque equivalence key that may group the logical project across hosts. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Project group key** — Persisted opaque equivalence key used to group projects across hosts. A normalized Git remote is the current producer, but the concept is wider than repository identity and consumers must not parse or rederive it. Code: `projectGroupKey`.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
|
||||
@@ -17,7 +18,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,66 @@ export async function addOfflineHostAndReload(
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function addConnectedHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; port: number },
|
||||
): Promise<void> {
|
||||
await addConnectedHostsAndReload(page, [input]);
|
||||
}
|
||||
|
||||
export async function addConnectedHostsAndReload(
|
||||
page: Page,
|
||||
inputs: Array<{ serverId: string; label: string; port: number }>,
|
||||
): 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 }) => {
|
||||
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 }> = raw ? JSON.parse(raw) : [];
|
||||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
234
packages/app/e2e/sidebar-project-grouping.spec.ts
Normal file
234
packages/app/e2e/sidebar-project-grouping.spec.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addConnectedHostAndReload,
|
||||
addConnectedHostsAndReload,
|
||||
waitForConnectedHost,
|
||||
} from "./helpers/hosts";
|
||||
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, type TempDirectory } from "./helpers/workspace";
|
||||
|
||||
const SECONDARY_HOST_ID = "project-grouping-secondary";
|
||||
const SECONDARY_HOST_LABEL = "Secondary Host";
|
||||
const LEGACY_PRIMARY_HOST_ID = "project-grouping-legacy-primary";
|
||||
const LEGACY_SECONDARY_HOST_ID = "project-grouping-legacy-secondary";
|
||||
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
|
||||
|
||||
interface HostProject {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface CrossHostProject {
|
||||
secondaryHost: IsolatedHostDaemon;
|
||||
primary: HostProject;
|
||||
secondary: HostProject;
|
||||
}
|
||||
|
||||
interface ReconciledCrossHostProject extends CrossHostProject {
|
||||
primaryHost: IsolatedHostDaemon;
|
||||
}
|
||||
|
||||
async function createProject(
|
||||
client: SeedDaemonClient,
|
||||
repo: TempDirectory,
|
||||
serverId: string,
|
||||
): Promise<HostProject> {
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create project on ${serverId}`);
|
||||
}
|
||||
return {
|
||||
serverId,
|
||||
projectId: created.workspace.projectId,
|
||||
workspaceId: created.workspace.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectOneProjectContainsBothWorkspaces(
|
||||
page: Page,
|
||||
fixture: CrossHostProject,
|
||||
): Promise<void> {
|
||||
const primaryWorkspace = page.getByTestId(
|
||||
`sidebar-workspace-row-${fixture.primary.serverId}:${fixture.primary.workspaceId}`,
|
||||
);
|
||||
const secondaryWorkspace = page.getByTestId(
|
||||
`sidebar-workspace-row-${fixture.secondary.serverId}:${fixture.secondary.workspaceId}`,
|
||||
);
|
||||
await expect(primaryWorkspace).toBeVisible({ timeout: 30_000 });
|
||||
await expect(secondaryWorkspace).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.locator('[data-testid^="sidebar-project-row-"]')).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function readPersistedProjectGroupKey(host: IsolatedHostDaemon): Promise<unknown> {
|
||||
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
|
||||
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
return projects[0]?.projectGroupKey;
|
||||
}
|
||||
|
||||
async function removePersistedProjectGroupKeys(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.projectGroupKey;
|
||||
}
|
||||
await writeFile(projectsPath, JSON.stringify(projects));
|
||||
const persisted = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(persisted.every((project) => !("projectGroupKey" in project))).toBe(true);
|
||||
}
|
||||
|
||||
async function createReconciliationFixture(): Promise<{
|
||||
fixture: ReconciledCrossHostProject;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const primaryHost = await startIsolatedHostDaemon(LEGACY_PRIMARY_HOST_ID);
|
||||
const secondaryHost = await startIsolatedHostDaemon(LEGACY_SECONDARY_HOST_ID);
|
||||
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 {
|
||||
const primary = await createProject(primaryClient, primaryRepo, primaryHost.serverId);
|
||||
const secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
|
||||
await primaryClient.close();
|
||||
await secondaryClient.close();
|
||||
await removePersistedProjectGroupKeys(primaryHost);
|
||||
await removePersistedProjectGroupKeys(secondaryHost);
|
||||
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
|
||||
return {
|
||||
fixture: { primaryHost, secondaryHost, primary, secondary },
|
||||
cleanup: async () => {
|
||||
await primaryHost.close().catch(() => undefined);
|
||||
await secondaryHost.close().catch(() => undefined);
|
||||
await primaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryRepo.cleanup().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
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);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const test = base.extend<{
|
||||
crossHostProject: CrossHostProject;
|
||||
reconciledCrossHostProject: ReconciledCrossHostProject;
|
||||
}>({
|
||||
crossHostProject: async ({ page: _page }, provide) => {
|
||||
const secondaryHost = await startIsolatedHostDaemon(SECONDARY_HOST_ID);
|
||||
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: HostProject | null = null;
|
||||
let secondary: HostProject | null = null;
|
||||
|
||||
try {
|
||||
primary = await createProject(primaryClient, primaryRepo, getServerId());
|
||||
secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
|
||||
await provide({ secondaryHost, primary, secondary });
|
||||
} 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 resource = await createReconciliationFixture();
|
||||
try {
|
||||
await provide(resource.fixture);
|
||||
} finally {
|
||||
await resource.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Sidebar project grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("groups projects with the same Git remote across hosts", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
expect(crossHostProject.primary.projectId).not.toBe(crossHostProject.secondary.projectId);
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: crossHostProject.secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: crossHostProject.secondaryHost.port,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: crossHostProject.secondaryHost.serverId,
|
||||
endpoint: `localhost:${crossHostProject.secondaryHost.port}`,
|
||||
});
|
||||
await expectOneProjectContainsBothWorkspaces(page, crossHostProject);
|
||||
});
|
||||
|
||||
test("groups persisted projects missing group keys after app boot", async ({
|
||||
page,
|
||||
reconciledCrossHostProject,
|
||||
}) => {
|
||||
expect(reconciledCrossHostProject.primary.projectId).not.toBe(
|
||||
reconciledCrossHostProject.secondary.projectId,
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostsAndReload(page, [
|
||||
{
|
||||
serverId: reconciledCrossHostProject.primaryHost.serverId,
|
||||
label: "Legacy Primary Host",
|
||||
port: reconciledCrossHostProject.primaryHost.port,
|
||||
},
|
||||
{
|
||||
serverId: reconciledCrossHostProject.secondaryHost.serverId,
|
||||
label: "Legacy Secondary Host",
|
||||
port: reconciledCrossHostProject.secondaryHost.port,
|
||||
},
|
||||
]);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: reconciledCrossHostProject.primaryHost.serverId,
|
||||
endpoint: `localhost:${reconciledCrossHostProject.primaryHost.port}`,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: reconciledCrossHostProject.secondaryHost.serverId,
|
||||
endpoint: `localhost:${reconciledCrossHostProject.secondaryHost.port}`,
|
||||
});
|
||||
await expectOneProjectContainsBothWorkspaces(page, reconciledCrossHostProject);
|
||||
await expect
|
||||
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.primaryHost))
|
||||
.toBe("remote:github.com/paseo-e2e/grouped-project");
|
||||
await expect
|
||||
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.secondaryHost))
|
||||
.toBe("remote:github.com/paseo-e2e/grouped-project");
|
||||
});
|
||||
});
|
||||
@@ -908,7 +908,7 @@ function NewWorkspaceGhostRow({
|
||||
serverId: worktreeTarget.serverId,
|
||||
sourceDirectory: worktreeTarget.iconWorkingDir,
|
||||
displayName,
|
||||
projectId: project.projectKey,
|
||||
projectId: worktreeTarget.projectId ?? project.projectKey,
|
||||
}) as Href,
|
||||
);
|
||||
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
|
||||
@@ -988,7 +988,7 @@ function ProjectHeaderRow({
|
||||
serverId: worktreeTarget.serverId,
|
||||
sourceDirectory: worktreeTarget.iconWorkingDir,
|
||||
displayName,
|
||||
projectId: project.projectKey,
|
||||
projectId: worktreeTarget.projectId ?? project.projectKey,
|
||||
}) as Href,
|
||||
);
|
||||
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
|
||||
@@ -1755,7 +1755,6 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
void removeProjectFromHosts({
|
||||
projectKey: project.projectKey,
|
||||
targets: readiness.targets,
|
||||
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
import { resolveProjectGroupKey } from "@/projects/project-group-key";
|
||||
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
@@ -144,7 +145,11 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): SidebarWorkspaceEntry {
|
||||
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
||||
const projectKey = resolveProjectGroupKey({
|
||||
serverId: input.serverId,
|
||||
projectId: input.workspace.projectId,
|
||||
projectGroupKey: input.workspace.projectGroupKey,
|
||||
});
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
WorkspaceStructureHostPlacement,
|
||||
WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
import { resolveProjectGroupKey } from "@/projects/project-group-key";
|
||||
|
||||
export interface HostProjectListItem {
|
||||
projectKey: string;
|
||||
@@ -58,6 +59,7 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
|
||||
hosts: [
|
||||
{
|
||||
serverId: route.serverId,
|
||||
projectId: projectKey,
|
||||
iconWorkingDir,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
@@ -73,7 +75,11 @@ export function hostProjectFromWorkspace(input: {
|
||||
if (!input.workspace) {
|
||||
return null;
|
||||
}
|
||||
const projectKey = input.workspace.projectId.trim();
|
||||
const projectKey = resolveProjectGroupKey({
|
||||
serverId: input.serverId,
|
||||
projectId: input.workspace.projectId.trim(),
|
||||
projectGroupKey: input.workspace.projectGroupKey,
|
||||
});
|
||||
const iconWorkingDir = input.workspace.projectRootPath.trim();
|
||||
if (!projectKey || !iconWorkingDir) {
|
||||
return null;
|
||||
@@ -87,6 +93,7 @@ export function hostProjectFromWorkspace(input: {
|
||||
hosts: [
|
||||
{
|
||||
serverId: input.serverId,
|
||||
projectId: input.workspace.projectId,
|
||||
iconWorkingDir,
|
||||
canCreateWorktree: canCreate,
|
||||
},
|
||||
@@ -106,6 +113,10 @@ export function getHostProjectSourceDirectory(
|
||||
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
|
||||
}
|
||||
|
||||
export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null {
|
||||
return project.hosts.find((host) => host.serverId === serverId)?.projectId ?? project.projectKey;
|
||||
}
|
||||
|
||||
export function canCreateWorkspaceForHostProject(input: {
|
||||
project: HostProjectListItem;
|
||||
serverId: string;
|
||||
|
||||
@@ -189,7 +189,14 @@ describe("host project list", () => {
|
||||
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 }],
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
projectId: "project-a",
|
||||
iconWorkingDir: "/repo/a",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const hostBOnly = hostProject({
|
||||
projectKey: "host-b-project",
|
||||
@@ -283,7 +290,14 @@ describe("host project list", () => {
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
projectId: "project-a",
|
||||
iconWorkingDir: "/repo/a",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaceKeys: [],
|
||||
});
|
||||
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
|
||||
@@ -295,7 +309,14 @@ describe("host project list", () => {
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/a",
|
||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
projectId: "project-a",
|
||||
iconWorkingDir: "/repo/a",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaceKeys: ["host-a:workspace-a"],
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
canCreateWorktreeForProjectKind,
|
||||
filterWorkspaceProjectsForHost,
|
||||
getHostProjectSourceDirectory,
|
||||
getHostProjectId,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
resolveInitialWorkspaceProject,
|
||||
|
||||
13
packages/app/src/projects/project-group-key.ts
Normal file
13
packages/app/src/projects/project-group-key.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function resolveProjectGroupKey(input: {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectGroupKey?: string | null;
|
||||
}): string {
|
||||
if (input.projectGroupKey) return input.projectGroupKey;
|
||||
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove after 2027-01-28.
|
||||
// Older daemons used remote/path-shaped project IDs as their grouping key. New opaque IDs
|
||||
// must remain host-local when the new field is absent.
|
||||
return input.projectId.startsWith("prj_")
|
||||
? `host:${input.serverId}:project:${input.projectId}`
|
||||
: input.projectId;
|
||||
}
|
||||
@@ -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,8 +65,8 @@ 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 () => {
|
||||
@@ -69,7 +74,10 @@ describe("project remove policy", () => {
|
||||
|
||||
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 ?? input.project.projectKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (unsupportedServerIds.length > 0) {
|
||||
@@ -59,11 +64,11 @@ export function getCurrentProjectRemoveReadiness(
|
||||
}
|
||||
|
||||
export async function removeProjectFromHosts(input: {
|
||||
projectKey: string;
|
||||
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 +77,11 @@ export async function removeProjectFromHosts(input: {
|
||||
disconnectedServerIds.push(target.serverId);
|
||||
continue;
|
||||
}
|
||||
clients.push({ serverId: target.serverId, client });
|
||||
const projectId = target.projectId ?? input.projectKey;
|
||||
if (!projectId) {
|
||||
return { kind: "failed", serverIds: [target.serverId] };
|
||||
}
|
||||
clients.push({ serverId: target.serverId, projectId, client });
|
||||
}
|
||||
|
||||
if (disconnectedServerIds.length > 0) {
|
||||
@@ -80,8 +89,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,8 +1,10 @@
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
import { resolveProjectGroupKey } from "@/projects/project-group-key";
|
||||
|
||||
export interface WorkspaceStructureHostPlacement {
|
||||
serverId: string;
|
||||
projectId?: string;
|
||||
iconWorkingDir: string;
|
||||
canCreateWorktree: boolean;
|
||||
}
|
||||
@@ -74,9 +76,14 @@ export function buildWorkspaceStructureProjects(input: {
|
||||
|
||||
for (const session of input.sessions) {
|
||||
for (const emptyProject of session.emptyProjects ?? []) {
|
||||
const projectKey = emptyProject.projectId;
|
||||
const projectKey = resolveProjectGroupKey({
|
||||
serverId: session.serverId,
|
||||
projectId: emptyProject.projectId,
|
||||
projectGroupKey: emptyProject.projectGroupKey,
|
||||
});
|
||||
const placement = {
|
||||
serverId: session.serverId,
|
||||
projectId: emptyProject.projectId,
|
||||
iconWorkingDir: emptyProject.projectRootPath,
|
||||
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
|
||||
};
|
||||
@@ -101,7 +108,11 @@ export function buildWorkspaceStructureProjects(input: {
|
||||
}
|
||||
|
||||
for (const workspace of session.workspaces) {
|
||||
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
|
||||
const projectKey = resolveProjectGroupKey({
|
||||
serverId: session.serverId,
|
||||
projectId: workspace.projectId,
|
||||
projectGroupKey: workspace.projectGroupKey,
|
||||
});
|
||||
const existing = byProject.get(projectKey);
|
||||
|
||||
if (!existing) {
|
||||
@@ -118,6 +129,7 @@ export function buildWorkspaceStructureProjects(input: {
|
||||
session.serverId,
|
||||
{
|
||||
serverId: session.serverId,
|
||||
projectId: workspace.projectId,
|
||||
iconWorkingDir: workspace.projectRootPath,
|
||||
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
|
||||
},
|
||||
@@ -136,6 +148,7 @@ export function buildWorkspaceStructureProjects(input: {
|
||||
|
||||
existing.hosts.set(session.serverId, {
|
||||
serverId: session.serverId,
|
||||
projectId: workspace.projectId,
|
||||
iconWorkingDir: workspace.projectRootPath,
|
||||
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
|
||||
});
|
||||
|
||||
@@ -193,6 +193,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
|
||||
return {
|
||||
id: workspace.id,
|
||||
projectId: workspace.projectId,
|
||||
...(workspace.projectGroupKey ? { projectGroupKey: workspace.projectGroupKey } : {}),
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
@@ -1889,16 +1892,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(
|
||||
|
||||
@@ -241,7 +241,11 @@ function ProjectSettingsBody({
|
||||
projectName={project.projectName}
|
||||
projectKey={project.projectKey}
|
||||
/>
|
||||
<ProjectNameEditor project={project} client={client} />
|
||||
<ProjectNameEditor
|
||||
project={project}
|
||||
projectId={selectedHost.projectId ?? project.projectKey}
|
||||
client={client}
|
||||
/>
|
||||
</View>
|
||||
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
|
||||
</View>
|
||||
@@ -790,10 +794,11 @@ function ResolveSpinnerColor(): string {
|
||||
|
||||
interface ProjectNameEditorProps {
|
||||
project: ProjectSummary;
|
||||
projectId: string;
|
||||
client: DaemonClient;
|
||||
}
|
||||
|
||||
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
@@ -801,7 +806,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
const [value, setValue] = useState(project.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);
|
||||
|
||||
@@ -138,6 +138,7 @@ export interface Agent {
|
||||
export interface WorkspaceDescriptor {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectGroupKey?: string | null;
|
||||
projectDisplayName: string;
|
||||
projectCustomName?: string | null;
|
||||
projectRootPath: string;
|
||||
@@ -169,6 +170,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
return {
|
||||
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
projectGroupKey: payload.projectGroupKey ?? payload.project?.projectGroupKey ?? null,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
@@ -195,6 +197,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
|
||||
export interface EmptyProjectDescriptor {
|
||||
projectId: string;
|
||||
projectGroupKey?: string | null;
|
||||
projectDisplayName: string;
|
||||
projectCustomName: string | null;
|
||||
projectRootPath: string;
|
||||
@@ -206,6 +209,7 @@ export function normalizeEmptyProjectDescriptor(
|
||||
): EmptyProjectDescriptor {
|
||||
return {
|
||||
projectId: payload.projectId,
|
||||
projectGroupKey: payload.projectGroupKey ?? null,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
@@ -253,6 +257,7 @@ function emptyProjectDescriptorFromWorkspace(
|
||||
): EmptyProjectDescriptor {
|
||||
return {
|
||||
projectId: workspace.projectId,
|
||||
...(workspace.projectGroupKey ? { projectGroupKey: workspace.projectGroupKey } : {}),
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
|
||||
@@ -30,6 +30,7 @@ function workspace(input: {
|
||||
repoRoot: string;
|
||||
project?: ProjectPlacementPayload;
|
||||
projectId?: string;
|
||||
projectGroupKey?: string;
|
||||
projectName?: string;
|
||||
remoteUrl?: string | null;
|
||||
currentBranch?: string;
|
||||
@@ -38,6 +39,7 @@ function workspace(input: {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
|
||||
projectGroupKey: input.projectGroupKey,
|
||||
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
|
||||
projectRootPath: input.repoRoot,
|
||||
workspaceDirectory: input.repoRoot,
|
||||
@@ -64,6 +66,36 @@ function workspace(input: {
|
||||
}
|
||||
|
||||
describe("buildProjects", () => {
|
||||
it("groups distinct host-local project IDs by their persisted group key", () => {
|
||||
const projectGroupKey = "remote:github.com/acme/app";
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "desktop",
|
||||
serverName: "Desktop",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({ id: "main-a", repoRoot: "/a", projectId: "prj_a", projectGroupKey }),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({ id: "main-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts).toMatchObject([
|
||||
{ serverId: "desktop", projectId: "prj_a" },
|
||||
{ serverId: "laptop", projectId: "prj_b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes detached and blank branches out of workspace summaries", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
|
||||
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
|
||||
import { resolveProjectGroupKey } from "@/projects/project-group-key";
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
id: string;
|
||||
@@ -14,6 +15,7 @@ export interface WorkspaceSummary {
|
||||
|
||||
export interface ProjectHostEntry {
|
||||
serverId: string;
|
||||
projectId?: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
repoRoot: string;
|
||||
@@ -55,6 +57,7 @@ const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
|
||||
|
||||
interface HostGroup {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
@@ -137,6 +140,7 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
|
||||
group.workspaces[0];
|
||||
return {
|
||||
serverId: group.serverId,
|
||||
projectId: group.projectId,
|
||||
serverName: group.serverName,
|
||||
isOnline: group.isOnline,
|
||||
repoRoot,
|
||||
@@ -198,8 +202,11 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
}
|
||||
|
||||
if (!group.hostsByServerId.has(host.serverId)) {
|
||||
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
|
||||
if (!placement) continue;
|
||||
group.hostsByServerId.set(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
projectId: placement.projectId ?? hostProject.projectKey,
|
||||
serverName: host.serverName,
|
||||
isOnline: host.isOnline,
|
||||
workspaces: [],
|
||||
@@ -209,7 +216,12 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
}
|
||||
|
||||
for (const workspace of host.workspaces) {
|
||||
const group = groups.get(workspace.projectId);
|
||||
const key = resolveProjectGroupKey({
|
||||
serverId: host.serverId,
|
||||
projectId: workspace.projectId,
|
||||
projectGroupKey: workspace.projectGroupKey,
|
||||
});
|
||||
const group = groups.get(key);
|
||||
const hostGroup = group?.hostsByServerId.get(host.serverId);
|
||||
if (!hostGroup) continue;
|
||||
hostGroup.workspaces.push(workspace);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
export interface SidebarProjectHostTarget {
|
||||
serverId: string;
|
||||
projectId?: string;
|
||||
iconWorkingDir: string;
|
||||
}
|
||||
|
||||
@@ -21,13 +22,14 @@ 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(
|
||||
|
||||
@@ -299,6 +299,7 @@ function createLegacyWorkspace(
|
||||
return {
|
||||
id: workspaceDirectory,
|
||||
projectId: entry.project.projectKey,
|
||||
projectGroupKey: entry.project.projectGroupKey ?? null,
|
||||
projectDisplayName: entry.project.projectName,
|
||||
projectCustomName: null,
|
||||
projectRootPath,
|
||||
|
||||
@@ -3001,6 +3001,8 @@ export const ProjectCheckoutLitePayloadSchema = z.union([
|
||||
|
||||
export const ProjectPlacementPayloadSchema = z.object({
|
||||
projectKey: z.string(),
|
||||
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
|
||||
projectGroupKey: z.string().optional(),
|
||||
projectName: z.string(),
|
||||
workspaceName: z.string().nullable().optional(),
|
||||
checkout: ProjectCheckoutLitePayloadSchema,
|
||||
@@ -3090,6 +3092,8 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
|
||||
projectGroupKey: z.string().optional(),
|
||||
projectDisplayName: z.string(),
|
||||
// COMPAT(projectCustomName): added in v0.1.76, drop the optional gate when floor >= v0.1.76.
|
||||
// When the user has renamed a project, projectDisplayName carries the resolved
|
||||
@@ -3230,6 +3234,8 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
|
||||
// workspace is archived.
|
||||
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
|
||||
projectId: z.string(),
|
||||
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
|
||||
projectGroupKey: z.string().optional(),
|
||||
projectDisplayName: z.string(),
|
||||
projectCustomName: z.string().nullable().optional(),
|
||||
projectRootPath: z.string(),
|
||||
|
||||
@@ -859,7 +859,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();
|
||||
|
||||
42
packages/server/src/server/project-group-key.ts
Normal file
42
packages/server/src/server/project-group-key.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Derives the persisted, opaque equivalence key used to group projects across hosts.
|
||||
* A Git remote is today's strongest shared fact, but callers treat the result as opaque.
|
||||
*/
|
||||
export function deriveProjectGroupKey(input: {
|
||||
rootPath: string;
|
||||
remoteUrl: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
}): string {
|
||||
return (
|
||||
deriveRemoteProjectGroupKey(input.remoteUrl) ?? resolve(input.mainRepoRoot ?? input.rootPath)
|
||||
);
|
||||
}
|
||||
|
||||
function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null {
|
||||
const trimmed = remoteUrl?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let host: string | null = null;
|
||||
let remotePath: string | null = null;
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
||||
if (scpLike) {
|
||||
host = scpLike[1] ?? null;
|
||||
remotePath = scpLike[2] ?? null;
|
||||
} else if (trimmed.includes("://")) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
host = parsed.hostname || null;
|
||||
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!host || !remotePath) return null;
|
||||
let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
if (cleanedPath.endsWith(".git")) cleanedPath = cleanedPath.slice(0, -4);
|
||||
if (!cleanedPath.includes("/")) return null;
|
||||
return `remote:${host.toLowerCase()}/${cleanedPath}`;
|
||||
}
|
||||
@@ -1690,6 +1690,7 @@ export class Session {
|
||||
});
|
||||
return {
|
||||
projectKey: project.projectId,
|
||||
...(project.projectGroupKey ? { projectGroupKey: project.projectGroupKey } : {}),
|
||||
projectName: resolveProjectDisplayName(project),
|
||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
||||
checkout,
|
||||
@@ -4460,6 +4461,7 @@ export class Session {
|
||||
): WorkspaceProjectDescriptorPayload {
|
||||
return {
|
||||
projectId: project.projectId,
|
||||
...(project.projectGroupKey ? { projectGroupKey: project.projectGroupKey } : {}),
|
||||
projectDisplayName: resolveProjectDisplayName(project),
|
||||
projectCustomName: project.customName ?? null,
|
||||
projectRootPath: project.rootPath,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../../workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import { deriveProjectGroupKey } from "../../project-group-key.js";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
|
||||
export interface ResolveOrCreateWorkspaceIdInput {
|
||||
@@ -162,6 +163,11 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
rootPath,
|
||||
kind: checkout.isGit ? "git" : "non_git",
|
||||
displayName: basename(rootPath) || rootPath,
|
||||
projectGroupKey: deriveProjectGroupKey({
|
||||
rootPath,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
}),
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
@@ -256,10 +262,16 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
// Orphaned legacy workspace FKs fall through to exact-root allocation.
|
||||
}
|
||||
|
||||
const checkout = await workspaceGitService.getCheckout(input.repoRoot);
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: input.repoRoot,
|
||||
kind: "git",
|
||||
displayName: basename(input.repoRoot) || input.repoRoot,
|
||||
projectGroupKey: deriveProjectGroupKey({
|
||||
rootPath: input.repoRoot,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
}),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return refreshProjectKind(project);
|
||||
@@ -371,8 +383,18 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
? workspaceCheckout
|
||||
: await workspaceGitService.getCheckout(project.rootPath);
|
||||
const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git";
|
||||
if (project.kind === kind) return project;
|
||||
const refreshed = { ...project, kind, updatedAt: new Date().toISOString() };
|
||||
const projectGroupKey = deriveProjectGroupKey({
|
||||
rootPath: project.rootPath,
|
||||
remoteUrl: projectCheckout.remoteUrl,
|
||||
mainRepoRoot: projectCheckout.mainRepoRoot,
|
||||
});
|
||||
if (project.kind === kind && project.projectGroupKey === projectGroupKey) return project;
|
||||
const refreshed = {
|
||||
...project,
|
||||
kind,
|
||||
projectGroupKey,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await projectRegistry.upsert(refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
@@ -260,6 +260,7 @@ function createNoopProjectRegistry(): ProjectRegistry {
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
projectGroupKey: input.projectGroupKey ?? null,
|
||||
customName: null,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
|
||||
@@ -502,6 +502,7 @@ export class WorkspaceDirectory {
|
||||
)
|
||||
.map((project) => ({
|
||||
projectId: project.projectId,
|
||||
projectGroupKey: project.projectGroupKey ?? undefined,
|
||||
projectDisplayName: resolveProjectDisplayName(project),
|
||||
projectCustomName: project.customName ?? null,
|
||||
projectRootPath: project.rootPath,
|
||||
|
||||
@@ -777,6 +777,8 @@ describe("WorkspaceReconciliationService", () => {
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied.map((change) => change.kind).sort()).toEqual([
|
||||
"project_updated",
|
||||
"project_updated",
|
||||
"workspace_updated",
|
||||
"workspace_updated",
|
||||
]);
|
||||
@@ -785,6 +787,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
rootPath: repoDir,
|
||||
displayName: "blank-dot-page/editor",
|
||||
customName: null,
|
||||
projectGroupKey: "remote:github.com/blank-dot-page/editor",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(projects.get(repoDir)).toMatchObject({
|
||||
@@ -792,6 +795,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
rootPath: repoDir,
|
||||
displayName: "editor",
|
||||
customName: "Editor",
|
||||
projectGroupKey: "remote:github.com/blank-dot-page/editor",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(workspaces.get("focused-bat")).toMatchObject({
|
||||
@@ -804,7 +808,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps project display name stable when git remote changes", async () => {
|
||||
test("updates the group key while keeping the project display name stable", async () => {
|
||||
const dir = createTempGitRepo("reconcile-remote-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -856,8 +860,11 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied.find((c) => c.kind === "project_updated")).toBeUndefined();
|
||||
expect(result.changesApplied.find((c) => c.kind === "project_updated")).toMatchObject({
|
||||
fields: { projectGroupKey: "remote:github.com/new-owner/new-repo" },
|
||||
});
|
||||
expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo");
|
||||
expect(projects.get("p1")!.projectGroupKey).toBe("remote:github.com/new-owner/new-repo");
|
||||
});
|
||||
|
||||
test("keeps custom and default names stable when the remote changes", async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type MutableWorkspacePlacement,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { workspaceIdsForProjects } from "./workspace-directory.js";
|
||||
import { deriveProjectGroupKey } from "./project-group-key.js";
|
||||
|
||||
const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000;
|
||||
const DEFAULT_DEBOUNCE_MS = 100;
|
||||
@@ -66,7 +67,7 @@ export type ReconciliationChange =
|
||||
kind: "project_updated";
|
||||
projectId: string;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedProjectRecord, "kind">>;
|
||||
fields: Partial<Pick<PersistedProjectRecord, "kind" | "projectGroupKey">>;
|
||||
}
|
||||
| {
|
||||
kind: "workspace_updated";
|
||||
@@ -280,6 +281,11 @@ export class WorkspaceReconciliationService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Runs the boot-time convergence path and publishes every affected workspace. */
|
||||
async reconcileNow(): Promise<void> {
|
||||
await this.reconcileObservedGitMetadata("full");
|
||||
}
|
||||
|
||||
private async reconcileGitMetadataForProjects(
|
||||
projectsToReconcile: PersistedProjectRecord[],
|
||||
workspacesByProject: Map<string, PersistedWorkspaceRecord[]>,
|
||||
@@ -334,12 +340,22 @@ export class WorkspaceReconciliationService {
|
||||
checkout: await readCheckout(workspace.cwd),
|
||||
})),
|
||||
);
|
||||
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind">> = {};
|
||||
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind" | "projectGroupKey">> = {};
|
||||
const mappedKind = deriveProjectKind(currentGit);
|
||||
const projectGroupKey = currentGit.remoteUrl
|
||||
? deriveProjectGroupKey({
|
||||
rootPath: project.rootPath,
|
||||
remoteUrl: currentGit.remoteUrl,
|
||||
mainRepoRoot: currentGit.mainRepoRoot,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (project.kind !== mappedKind) {
|
||||
projectUpdates.kind = mappedKind;
|
||||
}
|
||||
if (projectGroupKey && project.projectGroupKey !== projectGroupKey) {
|
||||
projectUpdates.projectGroupKey = projectGroupKey;
|
||||
}
|
||||
|
||||
if (Object.keys(projectUpdates).length > 0) {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import { deriveProjectGroupKey } from "./project-group-key.js";
|
||||
import {
|
||||
deriveProjectKind,
|
||||
deriveWorkspaceDisplayName,
|
||||
@@ -31,8 +32,8 @@ export function classifyDirectoryForProjectMembership(input: {
|
||||
}): DirectoryProjectMembership {
|
||||
const cwd = resolve(input.cwd);
|
||||
const checkout: ProjectCheckoutLitePayload = { ...input.checkout, cwd };
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd: checkout.worktreeRoot ?? cwd,
|
||||
const projectKey = deriveProjectGroupKey({
|
||||
rootPath: checkout.worktreeRoot ?? cwd,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
});
|
||||
@@ -55,49 +56,6 @@ function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLiteP
|
||||
return worktreeRoot ?? resolve(cwd);
|
||||
}
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) return null;
|
||||
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let host: string | null = null;
|
||||
let remotePath: string | null = null;
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
||||
if (scpLike) {
|
||||
host = scpLike[1] ?? null;
|
||||
remotePath = scpLike[2] ?? null;
|
||||
} else if (trimmed.includes("://")) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
host = parsed.hostname || null;
|
||||
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!host || !remotePath) return null;
|
||||
|
||||
let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
if (cleanedPath.endsWith(".git")) cleanedPath = cleanedPath.slice(0, -4);
|
||||
if (!cleanedPath.includes("/")) return null;
|
||||
|
||||
return `remote:${host.toLowerCase()}/${cleanedPath}`;
|
||||
}
|
||||
|
||||
function deriveProjectGroupingKey(options: {
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
}): string {
|
||||
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
|
||||
if (remoteKey) return remoteKey;
|
||||
|
||||
const mainRepoRoot = options.mainRepoRoot?.trim();
|
||||
return mainRepoRoot || options.cwd;
|
||||
}
|
||||
|
||||
function deriveProjectGroupingName(projectKey: string): string {
|
||||
if (projectKey.startsWith("remote:")) {
|
||||
const pathSegments = projectKey.slice("remote:".length).split("/").filter(Boolean).slice(1);
|
||||
|
||||
@@ -16,6 +16,12 @@ const PersistedProjectRecordSchema = z.object({
|
||||
rootPath: z.string(),
|
||||
kind: z.enum(["git", "non_git"]),
|
||||
displayName: z.string(),
|
||||
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
|
||||
projectGroupKey: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
// User-set override layered over the derived displayName. Reconciliation
|
||||
// never touches this. Null means "use the derived name". Added for #987.
|
||||
customName: z
|
||||
@@ -104,6 +110,7 @@ export interface ProjectRegistry {
|
||||
rootPath: string;
|
||||
kind: PersistedProjectKind;
|
||||
displayName: string;
|
||||
projectGroupKey?: string;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord>;
|
||||
upsert(record: PersistedProjectRecord): Promise<void>;
|
||||
@@ -308,6 +315,7 @@ export class FileBackedProjectRegistry
|
||||
rootPath: string;
|
||||
kind: PersistedProjectKind;
|
||||
displayName: string;
|
||||
projectGroupKey?: string;
|
||||
timestamp: string;
|
||||
}): Promise<PersistedProjectRecord> {
|
||||
const previous = this.allocationQueue;
|
||||
@@ -325,8 +333,17 @@ export class FileBackedProjectRegistry
|
||||
left.projectId.localeCompare(right.projectId),
|
||||
)[0];
|
||||
if (active) {
|
||||
if (active.kind === input.kind) return active;
|
||||
const refreshed = { ...active, kind: input.kind, updatedAt: input.timestamp };
|
||||
if (
|
||||
active.kind === input.kind &&
|
||||
active.projectGroupKey === (input.projectGroupKey ?? null)
|
||||
)
|
||||
return active;
|
||||
const refreshed = {
|
||||
...active,
|
||||
kind: input.kind,
|
||||
projectGroupKey: input.projectGroupKey ?? null,
|
||||
updatedAt: input.timestamp,
|
||||
};
|
||||
await this.upsert(refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
@@ -339,6 +356,7 @@ export class FileBackedProjectRegistry
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
projectGroupKey: input.projectGroupKey ?? null,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
});
|
||||
@@ -459,6 +477,7 @@ export function createPersistedProjectRecord(input: {
|
||||
kind: PersistedProjectKind;
|
||||
displayName: string;
|
||||
customName?: string | null;
|
||||
projectGroupKey?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
@@ -466,6 +485,7 @@ export function createPersistedProjectRecord(input: {
|
||||
return PersistedProjectRecordSchema.parse({
|
||||
...input,
|
||||
customName: input.customName ?? null,
|
||||
projectGroupKey: input.projectGroupKey ?? null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user