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";
|
} from "../support/helpers/new-workspace";
|
||||||
import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client";
|
import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
|
import { addConnectedHostsAndReload } from "../support/helpers/hosts";
|
||||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ interface CreatedProject {
|
|||||||
|
|
||||||
interface CrossHostProjectScenario {
|
interface CrossHostProjectScenario {
|
||||||
contextWorkspaceId: string;
|
contextWorkspaceId: string;
|
||||||
|
primarySharedWorkspaceId: string;
|
||||||
selectedProjectViewKey: string;
|
selectedProjectViewKey: string;
|
||||||
sharedProjectKey: string;
|
sharedProjectKey: string;
|
||||||
secondaryHost: {
|
secondaryHost: {
|
||||||
@@ -149,6 +151,7 @@ const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
|||||||
|
|
||||||
await provide({
|
await provide({
|
||||||
contextWorkspaceId: contextProject.workspaceId,
|
contextWorkspaceId: contextProject.workspaceId,
|
||||||
|
primarySharedWorkspaceId: primarySharedProject.workspaceId,
|
||||||
selectedProjectViewKey: projectPlacementViewKey(
|
selectedProjectViewKey: projectPlacementViewKey(
|
||||||
getServerId(),
|
getServerId(),
|
||||||
primarySharedProject.projectId,
|
primarySharedProject.projectId,
|
||||||
@@ -196,4 +199,33 @@ test.describe("New workspace host project preservation", () => {
|
|||||||
|
|
||||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
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,
|
serverId: input.serverId,
|
||||||
projectId,
|
projectId,
|
||||||
}),
|
}),
|
||||||
projectKey: null,
|
projectKey: input.workspace.project?.projectKey ?? null,
|
||||||
projectName: input.workspace.projectDisplayName || projectId,
|
projectName: input.workspace.projectDisplayName || projectId,
|
||||||
projectKind: input.workspace.projectKind,
|
projectKind: input.workspace.projectKind,
|
||||||
iconWorkingDir,
|
iconWorkingDir,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
getHostProjectSourceDirectory,
|
getHostProjectSourceDirectory,
|
||||||
getWorktreeSupportForHostProject,
|
getWorktreeSupportForHostProject,
|
||||||
hostProjectFromRoute,
|
hostProjectFromRoute,
|
||||||
|
hostProjectFromWorkspace,
|
||||||
} from "./host-project-model";
|
} from "./host-project-model";
|
||||||
|
import { normalizeWorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
|
||||||
function project(): HostProjectListItem {
|
function project(): HostProjectListItem {
|
||||||
return {
|
return {
|
||||||
@@ -95,4 +97,42 @@ describe("host project lookups", () => {
|
|||||||
hosts: [{ serverId: "host-a", projectId: "prj_a" }],
|
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,
|
getWorktreeSupportForHostProject,
|
||||||
hostProjectFromRoute,
|
hostProjectFromRoute,
|
||||||
hostProjectFromWorkspace,
|
hostProjectFromWorkspace,
|
||||||
|
resolveHostProjectCandidate,
|
||||||
useHostProjects,
|
useHostProjects,
|
||||||
type HostProjectListItem,
|
type HostProjectListItem,
|
||||||
} from "@/projects/host-projects";
|
} from "@/projects/host-projects";
|
||||||
@@ -1201,6 +1202,7 @@ interface NewWorkspaceInitialContextState {
|
|||||||
openHostPicker: () => void;
|
openHostPicker: () => void;
|
||||||
projects: HostProjectListItem[];
|
projects: HostProjectListItem[];
|
||||||
routeProject: HostProjectListItem | null;
|
routeProject: HostProjectListItem | null;
|
||||||
|
routeProjectContextViewKey: string | null;
|
||||||
lastActiveProject: HostProjectListItem | null;
|
lastActiveProject: HostProjectListItem | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1214,7 +1216,7 @@ function useNewWorkspaceInitialContext({
|
|||||||
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
||||||
const projects = useHostProjects(allServerIds);
|
const projects = useHostProjects(allServerIds);
|
||||||
const routeDisplayName = displayNameProp?.trim() ?? "";
|
const routeDisplayName = displayNameProp?.trim() ?? "";
|
||||||
const routeProject = useMemo(
|
const routePlacement = useMemo(
|
||||||
() =>
|
() =>
|
||||||
hostProjectFromRoute({
|
hostProjectFromRoute({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1224,6 +1226,16 @@ function useNewWorkspaceInitialContext({
|
|||||||
}),
|
}),
|
||||||
[projectId, routeDisplayName, serverId, sourceDirectoryProp],
|
[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 lastWorkspaceSelection = useLastWorkspaceSelection();
|
||||||
const lastWorkspaceServerId = useMemo(
|
const lastWorkspaceServerId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -1267,6 +1279,7 @@ function useNewWorkspaceInitialContext({
|
|||||||
openHostPicker,
|
openHostPicker,
|
||||||
projects,
|
projects,
|
||||||
routeProject,
|
routeProject,
|
||||||
|
routeProjectContextViewKey: routePlacement?.viewKey ?? null,
|
||||||
lastActiveProject,
|
lastActiveProject,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1548,6 +1561,7 @@ export function NewWorkspaceScreen({
|
|||||||
openHostPicker,
|
openHostPicker,
|
||||||
projects,
|
projects,
|
||||||
routeProject,
|
routeProject,
|
||||||
|
routeProjectContextViewKey,
|
||||||
lastActiveProject,
|
lastActiveProject,
|
||||||
} = useNewWorkspaceInitialContext({
|
} = useNewWorkspaceInitialContext({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1628,6 +1642,7 @@ export function NewWorkspaceScreen({
|
|||||||
selectedServerId,
|
selectedServerId,
|
||||||
projects,
|
projects,
|
||||||
routeProject,
|
routeProject,
|
||||||
|
routeProjectContextViewKey,
|
||||||
lastActiveProject,
|
lastActiveProject,
|
||||||
allowAllProjects: supportsWorkspaceMultiplicity,
|
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;
|
selectedServerId: string;
|
||||||
projects: HostProjectListItem[];
|
projects: HostProjectListItem[];
|
||||||
routeProject: HostProjectListItem | null;
|
routeProject: HostProjectListItem | null;
|
||||||
|
routeProjectContextViewKey: string | null;
|
||||||
lastActiveProject: HostProjectListItem | null;
|
lastActiveProject: HostProjectListItem | null;
|
||||||
allowAllProjects: boolean;
|
allowAllProjects: boolean;
|
||||||
}
|
}
|
||||||
@@ -84,6 +85,7 @@ export function useNewWorkspaceProjectPicker({
|
|||||||
selectedServerId,
|
selectedServerId,
|
||||||
projects,
|
projects,
|
||||||
routeProject,
|
routeProject,
|
||||||
|
routeProjectContextViewKey,
|
||||||
lastActiveProject,
|
lastActiveProject,
|
||||||
allowAllProjects,
|
allowAllProjects,
|
||||||
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
|
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
|
||||||
@@ -104,14 +106,13 @@ export function useNewWorkspaceProjectPicker({
|
|||||||
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
|
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const routeProjectViewKey = routeProject?.viewKey ?? null;
|
|
||||||
const selectionContextKey = createProjectSelectionContextKey({
|
const selectionContextKey = createProjectSelectionContextKey({
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
routeProjectViewKey,
|
routeProjectViewKey: routeProjectContextViewKey,
|
||||||
allowAllProjects,
|
allowAllProjects,
|
||||||
});
|
});
|
||||||
const manualSelectionContextKey = createManualProjectSelectionContextKey({
|
const manualSelectionContextKey = createManualProjectSelectionContextKey({
|
||||||
routeProjectViewKey,
|
routeProjectViewKey: routeProjectContextViewKey,
|
||||||
});
|
});
|
||||||
const shouldPreserveMissingProject = useCallback(
|
const shouldPreserveMissingProject = useCallback(
|
||||||
(project: HostProjectListItem) =>
|
(project: HostProjectListItem) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user