Keep the active project when switching workspace hosts (#3051)
* fix(app): preserve projects across host switches New workspace entry context retained only a host-local placement, so switching hosts could replace the selected project with the destination's first project. Hydrate that placement against canonical project identity while keeping exact clone placement separate. * fix(app): preserve manual project choice during hydration Keep the raw route placement key as stable selection context while hydrating the routed project to its canonical identity.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { addConnectedHostsAndReload } from "../support/helpers/hosts";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
@@ -29,6 +30,7 @@ interface CreatedProject {
|
||||
|
||||
interface CrossHostProjectScenario {
|
||||
contextWorkspaceId: string;
|
||||
primarySharedWorkspaceId: string;
|
||||
selectedProjectViewKey: string;
|
||||
sharedProjectKey: string;
|
||||
secondaryHost: {
|
||||
@@ -149,6 +151,7 @@ const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
||||
|
||||
await provide({
|
||||
contextWorkspaceId: contextProject.workspaceId,
|
||||
primarySharedWorkspaceId: primarySharedProject.workspaceId,
|
||||
selectedProjectViewKey: projectPlacementViewKey(
|
||||
getServerId(),
|
||||
primarySharedProject.projectId,
|
||||
@@ -196,4 +199,33 @@ test.describe("New workspace host project preservation", () => {
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
});
|
||||
|
||||
test("keeps the active workspace project selected when switching hosts", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openProjectDirectoryWithHosts(page, {
|
||||
hosts: [crossHostProject.secondaryHost],
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: crossHostProject.primarySharedWorkspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
|
||||
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
|
||||
await addConnectedHostsAndReload(page, [crossHostProject.secondaryHost], {
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ export function hostProjectFromWorkspace(input: {
|
||||
serverId: input.serverId,
|
||||
projectId,
|
||||
}),
|
||||
projectKey: null,
|
||||
projectKey: input.workspace.project?.projectKey ?? null,
|
||||
projectName: input.workspace.projectDisplayName || projectId,
|
||||
projectKind: input.workspace.projectKind,
|
||||
iconWorkingDir,
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
getHostProjectSourceDirectory,
|
||||
getWorktreeSupportForHostProject,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
} from "./host-project-model";
|
||||
import { normalizeWorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function project(): HostProjectListItem {
|
||||
return {
|
||||
@@ -95,4 +97,42 @@ describe("host project lookups", () => {
|
||||
hosts: [{ serverId: "host-a", projectId: "prj_a" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps canonical equivalence identity separate from host placement identity", () => {
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
id: "workspace-a",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "App",
|
||||
projectRootPath: "/repo/app",
|
||||
workspaceDirectory: "/repo/app",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
project: {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "App",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace })).toMatchObject({
|
||||
viewKey: JSON.stringify(["host-a", "project-a"]),
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
hosts: [{ serverId: "host-a", projectId: "project-a" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
getWorktreeSupportForHostProject,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
resolveHostProjectCandidate,
|
||||
useHostProjects,
|
||||
type HostProjectListItem,
|
||||
} from "@/projects/host-projects";
|
||||
@@ -1201,6 +1202,7 @@ interface NewWorkspaceInitialContextState {
|
||||
openHostPicker: () => void;
|
||||
projects: HostProjectListItem[];
|
||||
routeProject: HostProjectListItem | null;
|
||||
routeProjectContextViewKey: string | null;
|
||||
lastActiveProject: HostProjectListItem | null;
|
||||
}
|
||||
|
||||
@@ -1214,7 +1216,7 @@ function useNewWorkspaceInitialContext({
|
||||
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
||||
const projects = useHostProjects(allServerIds);
|
||||
const routeDisplayName = displayNameProp?.trim() ?? "";
|
||||
const routeProject = useMemo(
|
||||
const routePlacement = useMemo(
|
||||
() =>
|
||||
hostProjectFromRoute({
|
||||
serverId,
|
||||
@@ -1224,6 +1226,16 @@ function useNewWorkspaceInitialContext({
|
||||
}),
|
||||
[projectId, routeDisplayName, serverId, sourceDirectoryProp],
|
||||
);
|
||||
const routeProject = useMemo(() => {
|
||||
if (!routePlacement) return null;
|
||||
return (
|
||||
resolveHostProjectCandidate({
|
||||
candidate: routePlacement,
|
||||
projects,
|
||||
serverId,
|
||||
}) ?? routePlacement
|
||||
);
|
||||
}, [projects, routePlacement, serverId]);
|
||||
const lastWorkspaceSelection = useLastWorkspaceSelection();
|
||||
const lastWorkspaceServerId = useMemo(
|
||||
() =>
|
||||
@@ -1267,6 +1279,7 @@ function useNewWorkspaceInitialContext({
|
||||
openHostPicker,
|
||||
projects,
|
||||
routeProject,
|
||||
routeProjectContextViewKey: routePlacement?.viewKey ?? null,
|
||||
lastActiveProject,
|
||||
};
|
||||
}
|
||||
@@ -1548,6 +1561,7 @@ export function NewWorkspaceScreen({
|
||||
openHostPicker,
|
||||
projects,
|
||||
routeProject,
|
||||
routeProjectContextViewKey,
|
||||
lastActiveProject,
|
||||
} = useNewWorkspaceInitialContext({
|
||||
serverId,
|
||||
@@ -1628,6 +1642,7 @@ export function NewWorkspaceScreen({
|
||||
selectedServerId,
|
||||
projects,
|
||||
routeProject,
|
||||
routeProjectContextViewKey,
|
||||
lastActiveProject,
|
||||
allowAllProjects: supportsWorkspaceMultiplicity,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HostProjectListItem } from "@/projects/host-projects";
|
||||
import { useNewWorkspaceProjectPicker } from "./project-picker";
|
||||
|
||||
function project(input: {
|
||||
viewKey: string;
|
||||
projectKey: string | null;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}): HostProjectListItem {
|
||||
return {
|
||||
...input,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/work/${input.projectId}`,
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host",
|
||||
projectId: input.projectId,
|
||||
iconWorkingDir: `/work/${input.projectId}`,
|
||||
worktreeSupport: "supported",
|
||||
},
|
||||
],
|
||||
workspaceKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("useNewWorkspaceProjectPicker", () => {
|
||||
it("preserves a manual choice when the routed project hydrates", () => {
|
||||
const routePlacement = project({
|
||||
viewKey: '["host","route-local"]',
|
||||
projectKey: null,
|
||||
projectId: "route-local",
|
||||
projectName: "Route project",
|
||||
});
|
||||
const hydratedRouteProject = project({
|
||||
viewKey: "remote:github.com/acme/route",
|
||||
projectKey: "remote:github.com/acme/route",
|
||||
projectId: "route-local",
|
||||
projectName: "Route project",
|
||||
});
|
||||
const manualProject = project({
|
||||
viewKey: "remote:github.com/acme/manual",
|
||||
projectKey: "remote:github.com/acme/manual",
|
||||
projectId: "manual-local",
|
||||
projectName: "Manual project",
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ routeProject, projects }) =>
|
||||
useNewWorkspaceProjectPicker({
|
||||
selectedServerId: "host",
|
||||
projects,
|
||||
routeProject,
|
||||
routeProjectContextViewKey: routePlacement.viewKey,
|
||||
lastActiveProject: null,
|
||||
allowAllProjects: true,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
routeProject: routePlacement,
|
||||
projects: [routePlacement, manualProject],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const manualOption = result.current.projectPickerOptions.find(
|
||||
(option) => option.label === manualProject.projectName,
|
||||
);
|
||||
expect(manualOption).toBeDefined();
|
||||
act(() => result.current.handleSelectProjectOption(manualOption!.id));
|
||||
expect(result.current.selectedProject).toEqual(manualProject);
|
||||
|
||||
rerender({
|
||||
routeProject: hydratedRouteProject,
|
||||
projects: [hydratedRouteProject, manualProject],
|
||||
});
|
||||
|
||||
expect(result.current.selectedProject).toEqual(manualProject);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ interface NewWorkspaceProjectPickerInput {
|
||||
selectedServerId: string;
|
||||
projects: HostProjectListItem[];
|
||||
routeProject: HostProjectListItem | null;
|
||||
routeProjectContextViewKey: string | null;
|
||||
lastActiveProject: HostProjectListItem | null;
|
||||
allowAllProjects: boolean;
|
||||
}
|
||||
@@ -84,6 +85,7 @@ export function useNewWorkspaceProjectPicker({
|
||||
selectedServerId,
|
||||
projects,
|
||||
routeProject,
|
||||
routeProjectContextViewKey,
|
||||
lastActiveProject,
|
||||
allowAllProjects,
|
||||
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
|
||||
@@ -104,14 +106,13 @@ export function useNewWorkspaceProjectPicker({
|
||||
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
|
||||
);
|
||||
|
||||
const routeProjectViewKey = routeProject?.viewKey ?? null;
|
||||
const selectionContextKey = createProjectSelectionContextKey({
|
||||
selectedServerId,
|
||||
routeProjectViewKey,
|
||||
routeProjectViewKey: routeProjectContextViewKey,
|
||||
allowAllProjects,
|
||||
});
|
||||
const manualSelectionContextKey = createManualProjectSelectionContextKey({
|
||||
routeProjectViewKey,
|
||||
routeProjectViewKey: routeProjectContextViewKey,
|
||||
});
|
||||
const shouldPreserveMissingProject = useCallback(
|
||||
(project: HostProjectListItem) =>
|
||||
|
||||
Reference in New Issue
Block a user