Files
paseo/packages/app/src/screens/new-workspace/project-selection.test.ts
T
Mohamed BoudraandGitHub de44c8cd66 Show New Workspace isolation controls immediately (#2702)
* fix(workspaces): show isolation without checkout delay

* fix(workspaces): trust hydrated project capability

* fix(workspaces): resolve branch before creation

* fix(workspaces): honor invalidated checkout status

* fix(workspaces): preserve isolation while projects load

* fix(workspaces): keep provisional project routes usable

Route-derived projects do not know their worktree capability. Represent that uncertainty at the placement boundary so remembered worktree intent survives hydration without disabling the New Workspace screen indefinitely.
2026-07-31 17:50:35 +02:00

680 lines
21 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { filterWorkspaceProjectsForHost, type HostProjectListItem } from "@/projects/host-projects";
import {
createManualProjectSelectionContextKey,
createProjectSelectionContextKey,
createProjectSelection,
reconcileProjectSelection,
resolveInitialProjectSelectionSource,
resolveProjectSelection,
type ProjectSelection,
type ProjectSelectionContext,
} from "./project-selection";
function project(projectKey: string, serverId = "host"): HostProjectListItem {
return {
viewKey: `view:${serverId}:${projectKey}`,
projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/work/${projectKey}`,
hosts: [
{
serverId,
projectId: projectKey,
iconWorkingDir: `/work/${projectKey}`,
worktreeSupport: "supported" as const,
},
],
workspaceKeys: [],
};
}
function context(
input: Partial<ProjectSelectionContext> & {
initialProject: HostProjectListItem | null;
projects: HostProjectListItem[];
},
): ProjectSelectionContext {
const contextKey = input.contextKey ?? "host:";
const routeProject = input.routeProject ?? null;
const lastActiveProject = input.lastActiveProject ?? null;
return {
contextKey,
manualContextKey: input.manualContextKey ?? "",
selectedServerId: input.selectedServerId ?? "host",
routeProject,
lastActiveProject,
initialProjectSource:
input.initialProjectSource ??
resolveInitialProjectSelectionSource({
initialProject: input.initialProject,
routeProject,
lastActiveProject,
}),
shouldPreserveMissingProject: () => false,
...input,
};
}
describe("reconcileProjectSelection", () => {
it("keeps a still-selectable project when the default moves after archive", () => {
const remembered = project("remembered");
const other = project("other");
const current = createProjectSelection(
context({ initialProject: remembered, projects: [remembered, other] }),
);
const afterArchive = context({
initialProject: other,
projects: [other, remembered],
});
const reconciled = reconcileProjectSelection(current, afterArchive);
expect(reconciled).toEqual({
contextKey: "host:",
project: remembered,
source: "initial",
});
expect(resolveProjectSelection(reconciled, afterArchive)).toEqual(remembered);
});
it("resets stale selection when the route project context changes", () => {
const manual = project("manual");
const routeProject = { ...project("route-project"), projectKey: null };
const current: ProjectSelection = {
contextKey: "previous-route",
project: manual,
originProject: manual,
source: "manual",
};
const nextContext = context({
contextKey: "host:route-project",
manualContextKey: routeProject.viewKey,
initialProject: routeProject,
projects: [manual, routeProject],
routeProject,
});
expect(reconcileProjectSelection(current, nextContext)).toEqual({
contextKey: "host:route-project",
project: routeProject,
source: "initial",
});
});
it("hydrates an empty initial selection when projects arrive", () => {
const initialProject = project("hydrated");
const current = createProjectSelection(context({ initialProject: null, projects: [] }));
const hydratedContext = context({
initialProject,
projects: [initialProject],
});
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
contextKey: "host:",
project: initialProject,
source: "initial",
});
});
it("adopts a routed project's hydrated cross-host key", () => {
const routedProject = {
...project("local-project-id"),
projectKey: null,
hosts: [
{
serverId: "host",
projectId: "local-project-id",
iconWorkingDir: "/work/project",
worktreeSupport: "supported" as const,
},
],
};
const hydratedProject = {
...project("remote:github.com/acme/project"),
hosts: [
{
serverId: "host",
projectId: "local-project-id",
iconWorkingDir: "/work/project",
worktreeSupport: "unsupported" as const,
},
],
};
const current = createProjectSelection(
context({ initialProject: routedProject, projects: [], routeProject: routedProject }),
);
const hydratedContext = context({
initialProject: hydratedProject,
projects: [hydratedProject],
routeProject: routedProject,
});
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
contextKey: "host:",
project: hydratedProject,
source: "initial",
});
});
it("stores hydrated project snapshots before archive gaps", () => {
const routeProject = project("route-project");
const hydratedProject: HostProjectListItem = {
...routeProject,
workspaceKeys: ["host:workspace"],
};
const current = createProjectSelection(
context({ initialProject: routeProject, projects: [], routeProject }),
);
const afterHydration = context({
initialProject: hydratedProject,
projects: [hydratedProject],
routeProject,
});
const hydratedSelection = reconcileProjectSelection(current, afterHydration);
expect(hydratedSelection).toEqual({
contextKey: "host:",
project: hydratedProject,
source: "initial",
});
const archiveGap = context({
initialProject: routeProject,
projects: [],
routeProject,
shouldPreserveMissingProject: (candidate) =>
candidate.workspaceKeys.includes("host:workspace"),
});
expect(resolveProjectSelection(hydratedSelection, archiveGap)).toEqual(hydratedProject);
});
it("resets an automatic fallback when the remembered project hydrates", () => {
const fallback = project("fallback");
const remembered = project("remembered");
const current = createProjectSelection(
context({ initialProject: fallback, projects: [fallback, remembered] }),
);
const afterRememberedHydration = context({
initialProject: remembered,
projects: [fallback, remembered],
lastActiveProject: remembered,
});
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual({
contextKey: "host:",
project: remembered,
source: "initial",
});
});
it("keeps manual selections when the remembered project hydrates", () => {
const manual = project("manual");
const remembered = project("remembered");
const current: ProjectSelection = {
contextKey: "",
project: manual,
originProject: manual,
source: "manual",
};
const afterRememberedHydration = context({
initialProject: remembered,
projects: [manual, remembered],
lastActiveProject: remembered,
});
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
});
it("resolves a manual selection on another host while retaining its origin snapshot", () => {
const selected = project("shared", "host-a");
const equivalent = project("shared", "host-b");
const fallback = project("fallback", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const nextHost = context({
selectedServerId: "host-b",
initialProject: fallback,
projects: [fallback, equivalent],
});
const reconciled = reconcileProjectSelection(current, nextHost);
expect(reconciled).toEqual({
contextKey: "",
project: equivalent,
originProject: selected,
source: "manual",
});
expect(resolveProjectSelection(reconciled, nextHost)).toBe(equivalent);
});
it("ignores a stale placement view collision and selects the persisted equivalent", () => {
const stalePlacementViewKey = JSON.stringify(["host-b", "opaque-project"]);
const selected = {
...project("shared", "host-a"),
viewKey: stalePlacementViewKey,
};
const unrelated = {
...project(stalePlacementViewKey, "host-b"),
viewKey: stalePlacementViewKey,
};
const equivalent = project("shared", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const nextHost = context({
selectedServerId: "host-b",
initialProject: unrelated,
projects: [unrelated, equivalent],
});
const reconciled = reconcileProjectSelection(current, nextHost);
expect(reconciled).toEqual({
contextKey: "",
project: equivalent,
originProject: selected,
source: "manual",
});
expect(resolveProjectSelection(reconciled, nextHost)).toBe(equivalent);
});
it("chooses the same equivalent clone regardless of input order", () => {
const selected = project("shared", "host-a");
const cloneA = {
...project("shared", "host-b"),
viewKey: "view:host-b:clone-a",
projectName: "Shared",
hosts: [
{
serverId: "host-b",
projectId: "prj_a",
iconWorkingDir: "/work/a",
worktreeSupport: "supported" as const,
},
],
};
const cloneB = {
...cloneA,
viewKey: "view:host-b:clone-b",
hosts: [{ ...cloneA.hosts[0]!, projectId: "prj_b", iconWorkingDir: "/work/b" }],
};
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const resolve = (projects: HostProjectListItem[]) => {
const nextHost = context({
selectedServerId: "host-b",
initialProject: cloneB,
projects,
});
return resolveProjectSelection(reconcileProjectSelection(current, nextHost), nextHost);
};
expect(resolve([cloneB, cloneA])).toBe(cloneA);
expect(resolve([cloneA, cloneB])).toBe(cloneA);
});
it("restores the exact manually selected clone after switching away and back", () => {
const selected = {
...project("shared", "host-a"),
viewKey: "view:host-a:selected",
hosts: [
{
serverId: "host-a",
projectId: "prj_b",
iconWorkingDir: "/work/selected",
worktreeSupport: "supported" as const,
},
],
};
const sibling = {
...selected,
viewKey: "view:host-a:sibling",
hosts: [{ ...selected.hosts[0]!, projectId: "prj_a", iconWorkingDir: "/work/sibling" }],
};
const destination = project("shared", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const otherHost = context({
selectedServerId: "host-b",
initialProject: destination,
projects: [destination],
});
const retained = reconcileProjectSelection(current, otherHost);
expect(retained).toEqual({
contextKey: "",
project: destination,
originProject: selected,
source: "manual",
});
expect(resolveProjectSelection(retained, otherHost)).toBe(destination);
const originHost = context({
selectedServerId: "host-a",
initialProject: sibling,
projects: [sibling, selected],
});
const restored = reconcileProjectSelection(retained, originHost);
expect(restored.project).toBe(selected);
expect(restored.source === "manual" ? restored.originProject : null).toBe(selected);
expect(resolveProjectSelection(restored, originHost)).toBe(selected);
});
it("preserves the current destination clone during its pending archive gap", () => {
const selected = project("shared", "host-a");
const destination = {
...project("shared", "host-b"),
workspaceKeys: ["host-b:workspace"],
};
const fallback = project("fallback", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const destinationHost = context({
selectedServerId: "host-b",
initialProject: fallback,
projects: [fallback, destination],
});
const resolvedDestination = reconcileProjectSelection(current, destinationHost);
const archiveGap = context({
selectedServerId: "host-b",
initialProject: fallback,
projects: [fallback],
shouldPreserveMissingProject: (candidate) => candidate === destination,
});
expect(reconcileProjectSelection(resolvedDestination, archiveGap)).toBe(resolvedDestination);
expect(resolveProjectSelection(resolvedDestination, archiveGap)).toBe(destination);
});
it("preserves the exact origin clone during its pending archive gap", () => {
const selected = {
...project("shared", "host-a"),
viewKey: "view:host-a:selected",
workspaceKeys: ["host-a:workspace"],
hosts: [
{
serverId: "host-a",
projectId: "prj_b",
iconWorkingDir: "/work/selected",
worktreeSupport: "supported" as const,
},
],
};
const sibling = {
...selected,
viewKey: "view:host-a:sibling",
workspaceKeys: [],
hosts: [{ ...selected.hosts[0]!, projectId: "prj_a", iconWorkingDir: "/work/sibling" }],
};
const destination = project("shared", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const destinationHost = context({
selectedServerId: "host-b",
initialProject: destination,
projects: [destination],
});
const resolvedDestination = reconcileProjectSelection(current, destinationHost);
const archiveGap = context({
selectedServerId: "host-a",
initialProject: sibling,
projects: [sibling],
shouldPreserveMissingProject: (candidate) => candidate === selected,
});
const preservedOrigin = reconcileProjectSelection(resolvedDestination, archiveGap);
expect(preservedOrigin.project).toBe(selected);
expect(resolveProjectSelection(preservedOrigin, archiveGap)).toBe(selected);
});
it("falls back when the only equivalent project is not selectable", () => {
const selected = project("shared", "host-a");
const unselectableEquivalent = {
...project("shared", "host-b"),
hosts: [
{
serverId: "host-b",
projectId: "shared",
iconWorkingDir: "/work/shared",
worktreeSupport: "unsupported" as const,
},
],
};
const fallback = project("fallback", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
const selectableProjects = filterWorkspaceProjectsForHost({
projects: [unselectableEquivalent, fallback],
serverId: "host-b",
allowAllProjects: false,
});
const selectableContext = context({
selectedServerId: "host-b",
initialProject: fallback,
projects: selectableProjects,
});
expect(reconcileProjectSelection(current, selectableContext).project).toBe(fallback);
});
it("does not equate projects without a persisted project key", () => {
const selected = { ...project("selected", "host-a"), projectKey: null };
const sameName = {
...project("same-name", "host-b"),
projectKey: null,
projectName: selected.projectName,
};
const fallback = project("fallback", "host-b");
const current: ProjectSelection = {
contextKey: "",
project: selected,
originProject: selected,
source: "manual",
};
expect(
reconcileProjectSelection(
current,
context({
selectedServerId: "host-b",
initialProject: fallback,
projects: [sameName, fallback],
}),
).project,
).toBe(fallback);
});
it("preserves an opaque project key ending in whitespace", () => {
const selected = project("host:project ");
const current = createProjectSelection(
context({ initialProject: selected, projects: [selected] }),
);
const currentContext = context({ initialProject: selected, projects: [selected] });
expect(resolveProjectSelection(current, currentContext)).toEqual(selected);
expect(reconcileProjectSelection(current, currentContext)).toEqual(current);
});
it("resets fallback selection when host project capability changes", () => {
const fallback = project("git-fallback");
const remembered = project("remembered-directory");
const current = createProjectSelection(
context({
contextKey: createProjectSelectionContextKey({
selectedServerId: "host",
routeProjectViewKey: null,
allowAllProjects: false,
}),
initialProject: fallback,
projects: [fallback, remembered],
}),
);
const afterCapabilityHydration = context({
contextKey: createProjectSelectionContextKey({
selectedServerId: "host",
routeProjectViewKey: null,
allowAllProjects: true,
}),
initialProject: remembered,
projects: [fallback, remembered],
});
expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({
contextKey: "host:all-projects:",
project: remembered,
source: "initial",
});
});
it("keeps a still-selectable manual selection when host project capability changes", () => {
const fallback = project("git-fallback");
const manual = project("manual-choice");
const remembered = project("remembered-directory");
const current: ProjectSelection = {
contextKey: createManualProjectSelectionContextKey({
routeProjectViewKey: null,
}),
project: manual,
originProject: manual,
source: "manual",
};
const afterCapabilityHydration = context({
contextKey: createProjectSelectionContextKey({
selectedServerId: "host",
routeProjectViewKey: null,
allowAllProjects: true,
}),
manualContextKey: createManualProjectSelectionContextKey({
routeProjectViewKey: null,
}),
initialProject: remembered,
projects: [fallback, manual, remembered],
});
const reconciled = reconcileProjectSelection(current, afterCapabilityHydration);
expect(reconciled).toEqual(current);
expect(resolveProjectSelection(reconciled, afterCapabilityHydration)).toEqual(manual);
});
it("keeps the selected project snapshot during a pending archive gap", () => {
const remembered = project("remembered");
const fallback = project("fallback");
const current = createProjectSelection(
context({ initialProject: remembered, projects: [remembered] }),
);
const withoutRemembered = context({
initialProject: fallback,
projects: [fallback],
shouldPreserveMissingProject: (candidate) => candidate.projectKey === remembered.projectKey,
});
const reconciled = reconcileProjectSelection(current, withoutRemembered);
expect(reconciled).toEqual(current);
expect(resolveProjectSelection(reconciled, withoutRemembered)).toEqual(remembered);
});
it("keeps a pending archived clone instead of jumping to a same-host equivalent", () => {
const remembered = project("shared");
const sibling = {
...project("shared"),
viewKey: "view:host:sibling",
hosts: [
{
serverId: "host",
projectId: "sibling",
iconWorkingDir: "/work/sibling",
worktreeSupport: "supported" as const,
},
],
};
const fallback = project("fallback");
const current = createProjectSelection(
context({ initialProject: remembered, projects: [remembered, sibling] }),
);
const archiveGap = context({
initialProject: fallback,
projects: [sibling, fallback],
shouldPreserveMissingProject: (candidate) => candidate.viewKey === remembered.viewKey,
});
expect(reconcileProjectSelection(current, archiveGap)).toEqual(current);
expect(resolveProjectSelection(current, archiveGap)).toEqual(remembered);
});
it("falls back when the selected project disappears without a pending archive", () => {
const remembered = project("remembered");
const fallback = project("fallback");
const current = createProjectSelection(
context({ initialProject: remembered, projects: [remembered] }),
);
const withoutRemembered = context({
initialProject: fallback,
projects: [fallback],
});
expect(reconcileProjectSelection(current, withoutRemembered)).toEqual({
contextKey: "host:",
project: fallback,
source: "initial",
});
});
it("resolves manual selections from selectable projects, not route or remembered projects", () => {
const manual = project("manual");
const routeProject = project("route-project");
const remembered = project("remembered");
const current: ProjectSelection = {
contextKey: routeProject.viewKey,
project: manual,
originProject: manual,
source: "manual",
};
const selectionContext = context({
contextKey: "host:route-project",
manualContextKey: routeProject.viewKey,
initialProject: routeProject,
projects: [manual],
routeProject,
lastActiveProject: remembered,
});
expect(resolveProjectSelection(current, selectionContext)).toEqual(manual);
});
});