From 6544ca74190fa0d80c96c734c37f3a672f2dde08 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 28 Jul 2026 22:57:45 +0000 Subject: [PATCH] fix(projects): preserve selected project identity --- .../app/settings/projects/[projectKey].tsx | 4 +-- .../app/src/projects/host-project-model.ts | 14 +++++++- .../app/src/projects/host-projects.test.ts | 32 +++++++++++++++++++ packages/app/src/utils/host-routes.test.ts | 6 ++++ packages/app/src/utils/host-routes.ts | 5 +++ packages/app/src/utils/projects.test.ts | 14 ++++---- packages/app/src/utils/projects.ts | 6 ---- .../src/server/paseo-worktree-service.test.ts | 2 +- .../src/server/project-group-key.test.ts | 14 ++++++++ .../server/src/server/project-group-key.ts | 14 ++++++-- .../workspace-reconciliation-service.test.ts | 2 +- 11 files changed, 92 insertions(+), 21 deletions(-) diff --git a/packages/app/src/app/settings/projects/[projectKey].tsx b/packages/app/src/app/settings/projects/[projectKey].tsx index 5fe6f442a..340edb586 100644 --- a/packages/app/src/app/settings/projects/[projectKey].tsx +++ b/packages/app/src/app/settings/projects/[projectKey].tsx @@ -1,11 +1,11 @@ import { useLocalSearchParams } from "expo-router"; import { useMemo } from "react"; import SettingsScreen from "@/screens/settings-screen"; +import { normalizeProjectSettingsRouteKey } from "@/utils/host-routes"; export default function SettingsProjectDetailRoute() { const params = useLocalSearchParams<{ projectKey?: string | string[] }>(); - const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey; - const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : ""; + const projectKey = normalizeProjectSettingsRouteKey(params.projectKey); const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]); return ; diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts index 6c428705e..372159460 100644 --- a/packages/app/src/projects/host-project-model.ts +++ b/packages/app/src/projects/host-project-model.ts @@ -155,8 +155,20 @@ export function resolveInitialWorkspaceProject(input: { if (!candidate) { continue; } + const candidatePlacement = candidate.hosts.find( + (host) => host.serverId === input.serverId && host.projectId, + ); const hydratedProject = - input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate; + (candidatePlacement + ? input.projects.find((project) => + project.hosts.some( + (host) => + host.serverId === input.serverId && host.projectId === candidatePlacement.projectId, + ), + ) + : undefined) ?? + input.projects.find((project) => project.projectKey === candidate.projectKey) ?? + candidate; if ( canCreateWorkspaceForHostProject({ project: hydratedProject, diff --git a/packages/app/src/projects/host-projects.test.ts b/packages/app/src/projects/host-projects.test.ts index 6202fe3c1..e74ef719c 100644 --- a/packages/app/src/projects/host-projects.test.ts +++ b/packages/app/src/projects/host-projects.test.ts @@ -254,6 +254,38 @@ describe("host project list", () => { ).toEqual(selectedHostProject); }); + it("hydrates a route project by its host-local project id", () => { + const hydratedDirectory = hostProject({ + projectKey: "host:host-a:project:project-a", + projectName: "Notes", + projectKind: "directory", + hosts: [ + { + serverId: "host-a", + projectId: "project-a", + iconWorkingDir: "/notes", + canCreateWorktree: false, + }, + ], + }); + const routeDirectory = hostProjectFromRoute({ + serverId: "host-a", + projectId: "project-a", + displayName: "Notes", + sourceDirectory: "/notes", + }); + + expect( + resolveInitialWorkspaceProject({ + routeProject: routeDirectory, + lastActiveProject: null, + projects: [hydratedDirectory], + serverId: "host-a", + allowAllProjects: true, + }), + ).toEqual(hydratedDirectory); + }); + it("resolves the selected host project source directory", () => { const project = hostProject({ hosts: [ diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index fbc8115fc..830192816 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -17,6 +17,7 @@ import { encodeWorkspaceIdForPathSegment, isSettingsSectionSlug, normalizeHostSectionSlug, + normalizeProjectSettingsRouteKey, parseHostAgentRouteFromPathname, parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, @@ -214,6 +215,11 @@ describe("projects settings routes", () => { const segment = route.slice("/settings/projects/".length); expect(decodeURIComponent(segment)).toBe(projectKey); }); + + it("keeps an already-decoded opaque project key unchanged", () => { + const projectKey = "remote:github.com/acme/app#subdir:packages/my%20app"; + expect(normalizeProjectSettingsRouteKey(projectKey)).toBe(projectKey); + }); }); describe("global routes", () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 118ea2c40..c05d3c283 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -573,3 +573,8 @@ export function buildProjectSettingsRoute(projectKey: string) { } return `/settings/projects/${encodeSegment(normalized)}` as const; } + +export function normalizeProjectSettingsRouteKey(value: string | string[] | undefined): string { + const projectKey = Array.isArray(value) ? value[0] : value; + return typeof projectKey === "string" ? projectKey : ""; +} diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index 0d6fff18b..e72859aea 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -415,7 +415,7 @@ describe("buildProjects", () => { expect(result.projects[0]?.hostCount).toBe(1); }); - it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => { + it("uses the persisted selected project root instead of the checkout root", () => { const result = buildProjects({ hosts: [ { @@ -425,11 +425,11 @@ describe("buildProjects", () => { workspaces: [ workspace({ id: "main", - repoRoot: "/worktrees/app/main", + repoRoot: "/worktrees/app/main/packages/client", project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/worktrees/app/main", + projectKey: "remote:github.com/acme/app#subdir:packages/client", + projectName: "client", + cwd: "/worktrees/app/main/packages/client", remoteUrl: "https://github.com/acme/app.git", mainRepoRoot: "/repo/app", }), @@ -453,11 +453,11 @@ describe("buildProjects", () => { }); const acme = result.projects.find( - (project) => project.projectKey === "remote:github.com/acme/app", + (project) => project.projectKey === "remote:github.com/acme/app#subdir:packages/client", ); const legacy = result.projects.find((project) => project.projectKey === "legacy-project"); - expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app"); + expect(acme?.hosts[0]?.repoRoot).toBe("/worktrees/app/main/packages/client"); expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy"); }); diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index ed8f657a1..461f5a37b 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -119,12 +119,6 @@ function deriveGithubUrl(projectKey: string): string | undefined { } function resolveHostRepoRoot(group: HostGroup): string { - for (const workspace of group.workspaces) { - const mainRepoRoot = workspace.project?.checkout.mainRepoRoot; - if (mainRepoRoot) { - return mainRepoRoot; - } - } return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot; } diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 5396e05df..fb1875d3e 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -75,7 +75,7 @@ test("creates a worktree and registers it in the source workspace project withou expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(deps.projects.get(sourceProject.projectId)).toEqual({ ...sourceProject, - projectGroupKey: path.resolve(sourceProject.rootPath), + projectGroupKey: result.repoRoot, updatedAt: expect.any(String), }); expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]); diff --git a/packages/server/src/server/project-group-key.test.ts b/packages/server/src/server/project-group-key.test.ts index a7f79dcd2..5a5561a59 100644 --- a/packages/server/src/server/project-group-key.test.ts +++ b/packages/server/src/server/project-group-key.test.ts @@ -68,4 +68,18 @@ describe("deriveProjectGroupKey", () => { }), ).toBe("remote:github.com/getpaseo/paseo"); }); + + test("preserves the selected path for a checkout without a remote", () => { + const worktreeRoot = path.resolve("worktree"); + const mainRepoRoot = path.resolve("main"); + + expect( + deriveProjectGroupKey({ + rootPath: path.join(worktreeRoot, "packages", "app"), + remoteUrl: null, + worktreeRoot, + mainRepoRoot, + }), + ).toBe(path.join(mainRepoRoot, "packages", "app")); + }); }); diff --git a/packages/server/src/server/project-group-key.ts b/packages/server/src/server/project-group-key.ts index 7f554a178..0bebf9412 100644 --- a/packages/server/src/server/project-group-key.ts +++ b/packages/server/src/server/project-group-key.ts @@ -11,10 +11,14 @@ export function deriveProjectGroupKey(input: { mainRepoRoot: string | null; }): string { const remoteKey = deriveRemoteProjectGroupKey(input.remoteUrl); - if (!remoteKey) return resolve(input.mainRepoRoot ?? input.rootPath); - const selectedPath = deriveSelectedPath(input.rootPath, input.worktreeRoot); - return selectedPath ? `${remoteKey}#subdir:${selectedPath}` : remoteKey; + if (!remoteKey) { + return selectedPath && input.mainRepoRoot + ? resolve(input.mainRepoRoot, selectedPath) + : resolve(input.mainRepoRoot ?? input.rootPath); + } + + return selectedPath ? `${remoteKey}#subdir:${encodeSelectedPath(selectedPath)}` : remoteKey; } function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): string | null { @@ -29,6 +33,10 @@ function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): stri ) { return null; } + return selectedPath; +} + +function encodeSelectedPath(selectedPath: string): string { return selectedPath.split(sep).map(encodeURIComponent).join("/"); } diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index da393ebc2..c0ad811ec 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -1409,7 +1409,7 @@ describe("WorkspaceReconciliationService", () => { kind: "project_updated", projectId: "p1", directory: rootPath, - fields: { projectGroupKey: "/tmp/main-repo" }, + fields: { projectGroupKey: path.resolve("/tmp/main-repo") }, }, { kind: "workspace_updated",