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.
This commit is contained in:
Mohamed Boudra
2026-07-29 09:12:08 +00:00
parent 39e5006882
commit 649592fe73
40 changed files with 201 additions and 221 deletions

View File

@@ -222,9 +222,9 @@ test.describe("Project remove", () => {
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
readdedProjectId = readded.project?.projectId ?? "";
const readdedProjectGroupKey = readded.project?.projectGroupKey ?? "";
const readdedProjectKey = readded.project?.projectKey ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readdedProjectGroupKey).toBe(workspace.projectKey);
expect(readdedProjectKey).toBe(workspace.projectKey);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
@@ -233,7 +233,7 @@ test.describe("Project remove", () => {
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectGroupKey}`),
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
).toBeVisible({ timeout: 30_000 });
} finally {
if (readdedProjectId) {

View File

@@ -46,9 +46,9 @@ function requireWorkspace(payload: WorkspacePayload) {
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
const projectKey = workspace.projectGroupKey ?? workspace.project?.projectGroupKey;
const projectKey = workspace.projectKey ?? workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Workspace ${workspace.id} has no project group key`);
throw new Error(`Workspace ${workspace.id} has no project key`);
}
return {
workspaceId: workspace.id,

View File

@@ -8,8 +8,8 @@ export interface SeedWorkspaceDescriptor {
id: string;
name: string;
projectId: string;
projectGroupKey?: string;
project?: { projectGroupKey?: string };
projectKey?: string;
project?: { projectKey?: string };
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
@@ -27,7 +27,7 @@ export interface SeedDaemonClient {
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectGroupKey?: string;
projectKey?: string;
projectDisplayName: string;
projectRootPath: string;
} | null;
@@ -213,9 +213,9 @@ export async function seedWorkspace(options: {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
const projectKey = workspace.projectGroupKey ?? workspace.project?.projectGroupKey;
const projectKey = workspace.projectKey ?? workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${workspace.id} has no project group key`);
throw new Error(`Created workspace ${workspace.id} has no project key`);
}
return {
client,

View File

@@ -65,12 +65,9 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const projectKey =
createdWorkspace.workspace.projectGroupKey ??
createdWorkspace.workspace.project?.projectGroupKey;
createdWorkspace.workspace.projectKey ?? createdWorkspace.workspace.project?.projectKey;
if (!projectKey) {
throw new Error(
`Created workspace ${createdWorkspace.workspace.id} has no project group key`,
);
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,

View File

@@ -78,27 +78,27 @@ async function openGroupedProjectSettings(page: Page): Promise<void> {
await expect(page.getByTestId("host-picker")).toBeVisible({ timeout: 30_000 });
}
async function readPersistedProjectGroupKey(host: IsolatedHostDaemon): Promise<unknown> {
async function readPersistedProjectKey(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;
return projects[0]?.projectKey;
}
async function removePersistedProjectGroupKeys(host: IsolatedHostDaemon): Promise<void> {
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.projectGroupKey;
delete project.projectKey;
}
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);
expect(persisted.every((project) => !("projectKey" in project))).toBe(true);
}
async function rewritePersistedProjectId(
@@ -147,8 +147,8 @@ async function createReconciliationFixture(options?: { sharedLegacyProjectId?: s
let secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectGroupKeys(primaryHost);
await removePersistedProjectGroupKeys(secondaryHost);
await removePersistedProjectKeys(primaryHost);
await removePersistedProjectKeys(secondaryHost);
if (options?.sharedLegacyProjectId) {
await Promise.all([
rewritePersistedProjectId(primaryHost, primary.projectId, options.sharedLegacyProjectId),
@@ -256,7 +256,7 @@ test.describe("Sidebar project grouping", () => {
await expectOneProjectContainsBothWorkspaces(page, crossHostProject);
});
test("groups persisted projects missing group keys after app boot", async ({
test("groups persisted projects missing project keys after app boot", async ({
page,
reconciledCrossHostProject,
}) => {
@@ -286,10 +286,10 @@ test.describe("Sidebar project grouping", () => {
});
await expectOneProjectContainsBothWorkspaces(page, reconciledCrossHostProject);
await expect
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.primaryHost))
.poll(() => readPersistedProjectKey(reconciledCrossHostProject.primaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
await expect
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.secondaryHost))
.poll(() => readPersistedProjectKey(reconciledCrossHostProject.secondaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
});

View File

@@ -32,8 +32,8 @@ interface RestartDaemonClient {
name: string;
status: string;
workspaceDirectory: string;
projectGroupKey?: string;
project?: { projectGroupKey?: string };
projectKey?: string;
project?: { projectKey?: string };
}>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
@@ -467,7 +467,7 @@ test.describe("Workspace model restart regressions", () => {
(workspace) => workspace.id === seeded.workspaceA,
);
const reconciledProjectKey =
reconciledWorkspace?.projectGroupKey ?? reconciledWorkspace?.project?.projectGroupKey;
reconciledWorkspace?.projectKey ?? reconciledWorkspace?.project?.projectKey;
if (!reconciledProjectKey) {
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
}

View File

@@ -27,13 +27,13 @@ describe("selectActiveGitWorkspaceProject", () => {
});
});
it("uses the persisted project group key for the settings route", () => {
it("uses the persisted project key for the settings route", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({
projectId: "prj_local",
projectGroupKey: "remote:github.com/acme/project",
projectKey: "remote:github.com/acme/project",
}),
),
).toMatchObject({
@@ -101,7 +101,7 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
});
});
it("keeps the action route stable when the structural group key changes", () => {
it("keeps the action route stable when the structural project key changes", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",

View File

@@ -1,12 +1,12 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
import { resolveProjectKey } from "@/projects/project-key";
import { resolveHostProjectSettingsRouteKey } from "@/projects/project-settings-target";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectGroupKey?: string | null;
projectKey?: string | null;
projectKind: string;
projectRootPath: string;
}
@@ -43,10 +43,10 @@ export function selectActiveGitWorkspaceProject(
}
const projectId = workspace.projectId.trim();
const projectKey = resolveProjectGroupKey({
const projectKey = resolveProjectKey({
serverId,
projectId,
projectGroupKey: workspace.projectGroupKey,
projectKey: workspace.projectKey,
});
const repoRoot = workspace.projectRootPath.trim();
if (!projectId || !repoRoot) {

View File

@@ -115,14 +115,14 @@ function workspace(input: {
name: string;
projectId: string;
projectDisplayName: string;
projectGroupKey?: string;
projectKey?: string;
status?: WorkspaceDescriptor["status"];
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectGroupKey: input.projectGroupKey,
projectKey: input.projectKey,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
@@ -422,7 +422,7 @@ describe("shared sidebar workspace model", () => {
id: "clone-a",
name: "main",
projectId: "prj_a",
projectGroupKey: "remote:github.com/acme/app",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
}),
],

View File

@@ -8,7 +8,7 @@ import type {
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
import { resolveProjectKey } from "@/projects/project-key";
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
@@ -148,10 +148,10 @@ export function createSidebarWorkspaceEntry(input: {
}): SidebarWorkspaceEntry {
const projectKey =
input.projectKey ??
resolveProjectGroupKey({
resolveProjectKey({
serverId: input.serverId,
projectId: input.workspace.projectId,
projectGroupKey: input.workspace.projectGroupKey,
projectKey: input.workspace.projectKey,
});
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {

View File

@@ -93,7 +93,7 @@ describe("openProjectDirectly", () => {
serverId: SERVER_ID,
project: {
projectId: "project-1",
projectGroupKey: null,
projectKey: null,
projectDisplayName: "project",
projectCustomName: null,
projectKind: "git",
@@ -192,7 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
project: {
...projectPayload,
projectCustomName: null,
projectGroupKey: null,
projectKey: null,
},
},
]);

View File

@@ -3,7 +3,7 @@ import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
import { resolveProjectKey } from "@/projects/project-key";
export interface HostProjectListItem {
projectKey: string;
@@ -75,10 +75,10 @@ export function hostProjectFromWorkspace(input: {
if (!input.workspace) {
return null;
}
const projectKey = resolveProjectGroupKey({
const projectKey = resolveProjectKey({
serverId: input.serverId,
projectId: input.workspace.projectId.trim(),
projectGroupKey: input.workspace.projectGroupKey,
projectKey: input.workspace.projectKey,
});
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {

View File

@@ -1,10 +1,10 @@
export function resolveProjectGroupKey(input: {
export function resolveProjectKey(input: {
serverId: string;
projectId: string;
projectGroupKey?: string | null;
projectKey?: 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.
if (input.projectKey) return input.projectKey;
// COMPAT(projectKey): 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_") ? frameHostProjectKey(input) : input.projectId;

View File

@@ -1,4 +1,4 @@
import { frameHostProjectKey } from "./project-group-key";
import { frameHostProjectKey } from "./project-key";
interface ProjectSettingsTarget {
projectKey: string;

View File

@@ -11,7 +11,7 @@ function workspace(input: {
return {
id: input.id,
projectId: input.projectId ?? `project-${input.id}`,
projectGroupKey: "remote:github.com/acme/app",
projectKey: "remote:github.com/acme/app",
projectDisplayName: input.projectName,
projectCustomName: input.projectCustomName,
projectRootPath: `/repo/${input.id}`,

View File

@@ -1,6 +1,6 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { frameHostProjectKey, resolveProjectGroupKey } from "@/projects/project-group-key";
import { frameHostProjectKey, resolveProjectKey } from "@/projects/project-key";
export interface WorkspaceStructureHostPlacement {
serverId: string;
@@ -65,23 +65,21 @@ interface MaterializedWorkspaceStructureSession {
emptyProjects: EmptyProjectDescriptor[];
}
function findAmbiguousProjectGroupKeys(
sessions: MaterializedWorkspaceStructureSession[],
): Set<string> {
function findAmbiguousProjectKeys(sessions: MaterializedWorkspaceStructureSession[]): Set<string> {
const projectIdsByHostByGroupKey = new Map<string, Map<string, Set<string>>>();
for (const session of sessions) {
const projects = [
...session.emptyProjects.map((project) => ({
projectId: project.projectId,
projectGroupKey: project.projectGroupKey,
projectKey: project.projectKey,
})),
...session.workspaces.map((workspace) => ({
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
projectKey: workspace.projectKey,
})),
];
for (const project of projects) {
const groupKey = resolveProjectGroupKey({ serverId: session.serverId, ...project });
const groupKey = resolveProjectKey({ serverId: session.serverId, ...project });
const byHost = projectIdsByHostByGroupKey.get(groupKey) ?? new Map();
const projectIds = byHost.get(session.serverId) ?? new Set();
projectIds.add(project.projectId);
@@ -97,13 +95,13 @@ function findAmbiguousProjectGroupKeys(
);
}
function resolveUnambiguousProjectGroupKey(input: {
function resolveUnambiguousProjectKey(input: {
serverId: string;
projectId: string;
projectGroupKey?: string | null;
projectKey?: string | null;
ambiguousGroupKeys: ReadonlySet<string>;
}): string {
const groupKey = resolveProjectGroupKey(input);
const groupKey = resolveProjectKey(input);
return input.ambiguousGroupKeys.has(groupKey) ? frameHostProjectKey(input) : groupKey;
}
@@ -115,7 +113,7 @@ export function buildWorkspaceStructureProjects(input: {
workspaces: [...session.workspaces],
emptyProjects: [...(session.emptyProjects ?? [])],
}));
const ambiguousGroupKeys = findAmbiguousProjectGroupKeys(sessions);
const ambiguousGroupKeys = findAmbiguousProjectKeys(sessions);
const byProject = new Map<
string,
{
@@ -131,10 +129,10 @@ export function buildWorkspaceStructureProjects(input: {
for (const session of sessions) {
for (const emptyProject of session.emptyProjects) {
const projectKey = resolveUnambiguousProjectGroupKey({
const projectKey = resolveUnambiguousProjectKey({
serverId: session.serverId,
projectId: emptyProject.projectId,
projectGroupKey: emptyProject.projectGroupKey,
projectKey: emptyProject.projectKey,
ambiguousGroupKeys,
});
const placement = {
@@ -169,10 +167,10 @@ export function buildWorkspaceStructureProjects(input: {
}
for (const workspace of session.workspaces) {
const projectKey = resolveUnambiguousProjectGroupKey({
const projectKey = resolveUnambiguousProjectKey({
serverId: session.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
projectKey: workspace.projectKey,
ambiguousGroupKeys,
});
const existing = byProject.get(projectKey);

View File

@@ -103,7 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
kind: "upsert",
project: {
projectId: "attached",
projectGroupKey: "remote:github.com/acme/attached",
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
@@ -125,14 +125,14 @@ it("commits the authoritative snapshot before buffered project updates", () => {
const session = useSessionStore.getState().sessions[serverId];
expect(session?.workspaces.get(attachedMain.id)).toMatchObject({
projectGroupKey: "remote:github.com/acme/attached",
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
projectKind: "directory",
});
expect(session?.workspaces.get(attachedFeature.id)).toMatchObject({
projectGroupKey: "remote:github.com/acme/attached",
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectRootPath: "/moved/attached",
});

View File

@@ -42,7 +42,7 @@ function applyProjectDelta(
hasAttachedWorkspace = true;
snapshot.workspaces.set(workspaceId, {
...workspace,
projectGroupKey: project.projectGroupKey,
projectKey: project.projectKey,
projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName,
projectRootPath: project.projectRootPath,

View File

@@ -193,7 +193,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
return {
id: workspace.id,
projectId: workspace.projectId,
...(workspace.projectGroupKey ? { projectGroupKey: workspace.projectGroupKey } : {}),
...(workspace.projectKey ? { projectKey: workspace.projectKey } : {}),
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,

View File

@@ -138,7 +138,7 @@ export interface Agent {
export interface WorkspaceDescriptor {
id: string;
projectId: string;
projectGroupKey?: string | null;
projectKey?: string | null;
projectDisplayName: string;
projectCustomName?: string | null;
projectRootPath: string;
@@ -170,7 +170,7 @@ export function normalizeWorkspaceDescriptor(
return {
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
projectId: payload.projectId,
projectGroupKey: payload.projectGroupKey ?? payload.project?.projectGroupKey ?? null,
projectKey: payload.projectKey ?? payload.project?.projectKey ?? null,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
@@ -197,7 +197,7 @@ export function normalizeWorkspaceDescriptor(
export interface EmptyProjectDescriptor {
projectId: string;
projectGroupKey?: string | null;
projectKey?: string | null;
projectDisplayName: string;
projectCustomName: string | null;
projectRootPath: string;
@@ -209,7 +209,7 @@ export function normalizeEmptyProjectDescriptor(
): EmptyProjectDescriptor {
return {
projectId: payload.projectId,
projectGroupKey: payload.projectGroupKey ?? null,
projectKey: payload.projectKey ?? null,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
@@ -257,7 +257,7 @@ function emptyProjectDescriptorFromWorkspace(
): EmptyProjectDescriptor {
return {
projectId: workspace.projectId,
...(workspace.projectGroupKey ? { projectGroupKey: workspace.projectGroupKey } : {}),
...(workspace.projectKey ? { projectKey: workspace.projectKey } : {}),
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,

View File

@@ -30,7 +30,7 @@ function workspace(input: {
repoRoot: string;
project?: ProjectPlacementPayload;
projectId?: string;
projectGroupKey?: string;
projectKey?: string;
projectCustomName?: string | null;
projectName?: string;
remoteUrl?: string | null;
@@ -40,7 +40,7 @@ function workspace(input: {
return {
id: input.id,
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectGroupKey: input.projectGroupKey,
projectKey: input.projectKey,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectCustomName: input.projectCustomName,
projectRootPath: input.repoRoot,
@@ -68,25 +68,21 @@ function workspace(input: {
}
describe("buildProjects", () => {
it("groups distinct host-local project IDs by their persisted group key", () => {
const projectGroupKey = "remote:github.com/acme/app";
it("groups distinct host-local project IDs by their persisted project key", () => {
const projectKey = "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 }),
],
workspaces: [workspace({ id: "main-a", repoRoot: "/a", projectId: "prj_a", projectKey })],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({ id: "main-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
],
workspaces: [workspace({ id: "main-b", repoRoot: "/b", projectId: "prj_b", projectKey })],
},
],
});
@@ -98,8 +94,8 @@ describe("buildProjects", () => {
]);
});
it("keeps independent same-host clones separate when their group key is ambiguous", () => {
const projectGroupKey = "remote:github.com/acme/app";
it("keeps independent same-host clones separate when their project key is ambiguous", () => {
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
@@ -107,8 +103,8 @@ describe("buildProjects", () => {
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectGroupKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectKey }),
],
},
],
@@ -122,7 +118,7 @@ describe("buildProjects", () => {
});
it("uses one ambiguity scope across every host", () => {
const projectGroupKey = "remote:github.com/acme/app";
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
@@ -130,8 +126,8 @@ describe("buildProjects", () => {
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectGroupKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectKey }),
],
},
{
@@ -139,21 +135,21 @@ describe("buildProjects", () => {
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({ id: "clone-c", repoRoot: "/c", projectId: "prj_c", projectGroupKey }),
workspace({ id: "clone-c", repoRoot: "/c", projectId: "prj_c", projectKey }),
],
},
],
});
expect(result.projects.map((project) => project.projectKey).sort()).toEqual([
"host:desktop:project:prj_a",
"host:desktop:project:prj_b",
"host:laptop:project:prj_c",
"host:6:laptop:project:5:prj_c",
"host:7:desktop:project:5:prj_a",
"host:7:desktop:project:5:prj_b",
]);
});
it("keeps host-local metadata when the grouping key differs from the project ID", () => {
const projectGroupKey = "remote:github.com/acme/app";
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
@@ -165,7 +161,7 @@ describe("buildProjects", () => {
id: "main",
repoRoot: "/repo/app",
projectId: "prj_app",
projectGroupKey,
projectKey,
projectName: "acme/app",
projectCustomName: "My App",
}),
@@ -175,14 +171,14 @@ describe("buildProjects", () => {
});
expect(result.projects[0]).toMatchObject({
projectKey: projectGroupKey,
projectKey: projectKey,
projectName: "acme/app",
projectCustomName: "My App",
});
});
it("keeps custom-name state on the host that owns it", () => {
const projectGroupKey = "remote:github.com/acme/app";
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
@@ -194,7 +190,7 @@ describe("buildProjects", () => {
id: "desktop-main",
repoRoot: "/desktop/app",
projectId: "prj_desktop",
projectGroupKey,
projectKey,
projectName: "My App",
projectCustomName: "My App",
}),
@@ -209,7 +205,7 @@ describe("buildProjects", () => {
id: "laptop-main",
repoRoot: "/laptop/app",
projectId: "prj_laptop",
projectGroupKey,
projectKey,
projectName: "acme/app",
projectCustomName: null,
}),
@@ -235,7 +231,7 @@ describe("buildProjects", () => {
emptyProjects: [
{
projectId: "prj_app",
projectGroupKey: "remote:github.com/acme/app",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "My Empty Project",
projectCustomName: "My Empty Project",
projectRootPath: "/repo/app",

View File

@@ -1,7 +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";
import { resolveProjectKey } from "@/projects/project-key";
export interface WorkspaceSummary {
id: string;
@@ -238,10 +238,10 @@ function attachHostWorkspaces(
for (const workspace of host.workspaces) {
const key =
projectKeyByProjectId.get(workspace.projectId) ??
resolveProjectGroupKey({
resolveProjectKey({
serverId: host.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
projectKey: workspace.projectKey,
});
groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
}

View File

@@ -299,7 +299,7 @@ function createLegacyWorkspace(
return {
id: workspaceDirectory,
projectId: entry.project.projectKey,
projectGroupKey: entry.project.projectGroupKey ?? null,
projectKey: entry.project.projectKey ?? null,
projectDisplayName: entry.project.projectName,
projectCustomName: null,
projectRootPath,