fix(projects): preserve selected project identity

This commit is contained in:
Mohamed Boudra
2026-07-28 22:57:45 +00:00
parent ec08367d93
commit 6544ca7419
11 changed files with 92 additions and 21 deletions

View File

@@ -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 <SettingsScreen view={view} />;

View File

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

View File

@@ -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: [

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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