Keep New Workspace drafts when archiving a workspace (#1838)

* fix(app): preserve new workspace drafts across archive

The New Workspace project picker was still tied to the last active workspace. Archiving that workspace could make the picker fall back to another project, changing the draft key and clearing the composer.

* refactor(app): extract new workspace project selection

Keep the archive-draft fix out of the screen effect by moving the project selection transition into a small pure model with focused coverage.

* test(app): seed host in archive draft regression

Make the new Playwright regression self-contained by seeding the local host before opening the app shell.

* refactor(app): home new workspace project picker state

* fix(app): reset project picker on capability hydration

* fix(app): preserve manual project picks during hydration

* fix(app): keep project selection through archive gaps

* fix(app): narrow archive-gap project preservation

* fix(app): refresh new workspace project selection
This commit is contained in:
Mohamed Boudra
2026-07-01 21:53:16 +02:00
committed by GitHub
parent 31e9a210d0
commit 48d07dedb5
5 changed files with 715 additions and 127 deletions

View File

@@ -10,6 +10,7 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The per-project
@@ -105,6 +106,54 @@ test.describe("New workspace entry points", () => {
}
});
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
page,
}) => {
const otherProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "aa-new-workspace-archive-other-",
});
const rememberedProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "zz-new-workspace-archive-remembered-",
});
const serverId = getServerId();
const draftText = "keep this new workspace draft";
try {
await seedSavedSettingsHosts(page, [
{
serverId,
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page
.getByTestId(`sidebar-workspace-row-${serverId}:${rememberedProject.workspaceId}`)
.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await page.goto(`/new?serverId=${encodeURIComponent(serverId)}`);
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });
await composer.fill(draftText);
await expect(composer).toHaveValue(draftText);
await clickArchiveWorkspaceMenuItem(page, rememberedProject.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, rememberedProject.workspaceId);
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
await expect(composer).toHaveValue(draftText);
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
} finally {
await otherProject.cleanup();
await rememberedProject.cleanup();
}
});
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
page,
}) => {

View File

@@ -56,12 +56,9 @@ import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import {
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveSelectedHostProject,
useHostProjects,
type HostProjectListItem,
} from "@/projects/host-projects";
@@ -88,6 +85,7 @@ import {
resolveNewWorkspaceAutomaticServerId,
resolveNewWorkspaceInitialServerId,
} from "./new-workspace-initial-context";
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
function resolveCheckoutRequest(
selectedItem: PickerItem | null,
@@ -168,7 +166,6 @@ interface PickerSelection {
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:";
const PROJECT_OPTION_PREFIX = "project:";
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
// Height of a single picker-trigger badge. The Base-row spacer reserves exactly
// this so toggling Isolation to Local hides the row without shifting the form.
@@ -510,20 +507,6 @@ function prOptionId(number: number): string {
return `${PR_OPTION_PREFIX}${number}`;
}
function projectOptionId(projectId: string): string {
return `${PROJECT_OPTION_PREFIX}${projectId}`;
}
function computeProjectOptionData(projects: readonly HostProjectListItem[]) {
const projectByOptionId = new Map<string, HostProjectListItem>();
const options = projects.map((project) => {
const id = projectOptionId(project.projectKey);
projectByOptionId.set(id, project);
return { id, label: project.projectName };
});
return { options, projectByOptionId };
}
function NewWorkspacePickerOption({
option,
selected,
@@ -1287,7 +1270,6 @@ interface NewWorkspaceInitialContextState {
projects: HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
routeDisplayName: string;
}
function useNewWorkspaceInitialContext({
@@ -1354,112 +1336,6 @@ function useNewWorkspaceInitialContext({
projects,
routeProject,
lastActiveProject,
routeDisplayName,
};
}
interface NewWorkspaceProjectPickerInput {
selectedServerId: string;
projects: HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
displayName?: string;
allowAllProjects: boolean;
}
interface NewWorkspaceProjectPickerState {
projects: HostProjectListItem[];
selectedProject: HostProjectListItem | null;
selectedSourceDirectory: string | null;
selectedDisplayName: string;
projectPickerOptions: Array<{ id: string; label: string }>;
projectByOptionId: Map<string, HostProjectListItem>;
selectedProjectOptionId: string;
projectTriggerLabel: string;
handleSelectProjectOption: (id: string) => void;
}
function useNewWorkspaceProjectPicker({
selectedServerId,
projects,
routeProject,
lastActiveProject,
displayName: displayNameProp,
allowAllProjects,
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
const [manualProjectKey, setManualProjectKey] = useState<string | null>(null);
const displayName = displayNameProp?.trim() ?? "";
const selectableProjects = useMemo(
() =>
filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }),
[allowAllProjects, projects, selectedServerId],
);
const initialProject = useMemo(
() =>
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject,
projects: selectableProjects,
serverId: selectedServerId,
allowAllProjects,
}),
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
);
const routeProjectKey = routeProject?.projectKey ?? null;
useEffect(() => {
setManualProjectKey(null);
}, [routeProjectKey]);
const selectedProjectKey = useMemo(() => {
if (manualProjectKey) {
const manual = resolveSelectedHostProject({
selectedProjectKey: manualProjectKey,
projects: selectableProjects,
routeProject: null,
lastActiveProject: null,
});
if (manual) return manual.projectKey;
}
return initialProject?.projectKey ?? null;
}, [initialProject, manualProjectKey, selectableProjects]);
const selectedProject = useMemo(
() =>
resolveSelectedHostProject({
selectedProjectKey,
projects: selectableProjects,
routeProject,
lastActiveProject,
}),
[lastActiveProject, routeProject, selectableProjects, selectedProjectKey],
);
const { options: projectPickerOptions, projectByOptionId } = useMemo(
() => computeProjectOptionData(selectableProjects),
[selectableProjects],
);
const handleSelectProjectOption = useCallback(
(id: string) => {
const project = projectByOptionId.get(id);
if (!project) return;
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return;
setManualProjectKey(project.projectKey);
},
[allowAllProjects, projectByOptionId],
);
return {
projects,
selectedProject,
selectedSourceDirectory: selectedProject
? getHostProjectSourceDirectory(selectedProject, selectedServerId)
: null,
selectedDisplayName: selectedProject?.projectName ?? displayName,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "",
projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
handleSelectProjectOption,
};
}
@@ -1698,7 +1574,6 @@ export function NewWorkspaceScreen({
projects,
routeProject,
lastActiveProject,
routeDisplayName,
} = useNewWorkspaceInitialContext({
serverId,
sourceDirectory: sourceDirectoryProp,
@@ -1747,7 +1622,6 @@ export function NewWorkspaceScreen({
projects,
routeProject,
lastActiveProject,
displayName: routeDisplayName,
allowAllProjects: supportsWorkspaceMultiplicity,
});

View File

@@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
import { isWorkspaceArchivePending } from "@/contexts/session-workspace-upserts";
import {
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
resolveInitialWorkspaceProject,
type HostProjectListItem,
} from "@/projects/host-projects";
import {
createManualProjectSelectionContextKey,
createProjectSelectionContextKey,
createProjectSelection,
reconcileProjectSelection,
resolveInitialProjectSelectionSource,
resolveProjectSelection,
type ProjectSelection,
type ProjectSelectionContext,
} from "./project-selection";
const PROJECT_OPTION_PREFIX = "project:";
interface NewWorkspaceProjectPickerInput {
selectedServerId: string;
projects: HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
allowAllProjects: boolean;
}
interface NewWorkspaceProjectPickerState {
selectedProject: HostProjectListItem | null;
selectedSourceDirectory: string | null;
projectPickerOptions: ComboboxOptionType[];
projectByOptionId: Map<string, HostProjectListItem>;
selectedProjectOptionId: string;
projectTriggerLabel: string;
handleSelectProjectOption: (id: string) => void;
}
function projectOptionId(projectId: string): string {
return `${PROJECT_OPTION_PREFIX}${projectId}`;
}
function computeProjectOptionData(projects: readonly HostProjectListItem[]) {
const projectByOptionId = new Map<string, HostProjectListItem>();
const options = projects.map((project) => {
const id = projectOptionId(project.projectKey);
projectByOptionId.set(id, project);
return { id, label: project.projectName };
});
return { options, projectByOptionId };
}
function resolveWorkspaceIdFromProjectWorkspaceKey(input: {
selectedServerId: string;
workspaceKey: string;
}): string | null {
const prefix = `${input.selectedServerId}:`;
return input.workspaceKey.startsWith(prefix) ? input.workspaceKey.slice(prefix.length) : null;
}
function hasPendingArchiveForProject(input: {
selectedServerId: string;
project: HostProjectListItem;
}): boolean {
for (const workspaceKey of input.project.workspaceKeys) {
const workspaceId = resolveWorkspaceIdFromProjectWorkspaceKey({
selectedServerId: input.selectedServerId,
workspaceKey,
});
if (
workspaceId &&
isWorkspaceArchivePending({ serverId: input.selectedServerId, workspaceId })
) {
return true;
}
}
const workspaceDirectory = getHostProjectSourceDirectory(input.project, input.selectedServerId);
return isWorkspaceArchivePending({
serverId: input.selectedServerId,
workspaceDirectory,
});
}
export function useNewWorkspaceProjectPicker({
selectedServerId,
projects,
routeProject,
lastActiveProject,
allowAllProjects,
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
const selectableProjects = useMemo(
() =>
filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }),
[allowAllProjects, projects, selectedServerId],
);
const initialProject = useMemo(
() =>
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject,
projects: selectableProjects,
serverId: selectedServerId,
allowAllProjects,
}),
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
);
const routeProjectKey = routeProject?.projectKey ?? null;
const selectionContextKey = createProjectSelectionContextKey({
selectedServerId,
routeProjectKey,
allowAllProjects,
});
const manualSelectionContextKey = createManualProjectSelectionContextKey({
selectedServerId,
routeProjectKey,
});
const shouldPreserveMissingProject = useCallback(
(project: HostProjectListItem) =>
hasPendingArchiveForProject({
selectedServerId,
project,
}),
[selectedServerId],
);
const selectionContext = useMemo<ProjectSelectionContext>(
() => ({
contextKey: selectionContextKey,
manualContextKey: manualSelectionContextKey,
initialProject,
initialProjectSource: resolveInitialProjectSelectionSource({
initialProject,
routeProject,
lastActiveProject,
}),
projects: selectableProjects,
routeProject,
lastActiveProject,
shouldPreserveMissingProject,
}),
[
initialProject,
lastActiveProject,
manualSelectionContextKey,
routeProject,
selectableProjects,
selectionContextKey,
shouldPreserveMissingProject,
],
);
const [projectSelection, setProjectSelection] = useState<ProjectSelection>(() =>
createProjectSelection(selectionContext),
);
useEffect(() => {
setProjectSelection((current) => reconcileProjectSelection(current, selectionContext));
}, [selectionContext]);
const activeSelection = reconcileProjectSelection(projectSelection, selectionContext);
const selectedProject = resolveProjectSelection(activeSelection, selectionContext);
const { options: projectPickerOptions, projectByOptionId } = useMemo(
() => computeProjectOptionData(selectableProjects),
[selectableProjects],
);
const handleSelectProjectOption = useCallback(
(id: string) => {
const project = projectByOptionId.get(id);
if (!project) return;
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return;
setProjectSelection({
contextKey: manualSelectionContextKey,
projectKey: project.projectKey,
project,
source: "manual",
});
},
[allowAllProjects, manualSelectionContextKey, projectByOptionId],
);
return {
selectedProject,
selectedSourceDirectory: selectedProject
? getHostProjectSourceDirectory(selectedProject, selectedServerId)
: null,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "",
projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
handleSelectProjectOption,
};
}

View File

@@ -0,0 +1,309 @@
import { describe, expect, it } from "vitest";
import 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 {
projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/work/${projectKey}`,
hosts: [{ serverId, iconWorkingDir: `/work/${projectKey}`, canCreateWorktree: true }],
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 ?? contextKey,
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:",
projectKey: remembered.projectKey,
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");
const current: ProjectSelection = {
contextKey: "host:previous-route",
projectKey: manual.projectKey,
project: manual,
source: "manual",
};
const nextContext = context({
contextKey: "host:route-project",
initialProject: routeProject,
projects: [manual, routeProject],
routeProject,
});
expect(reconcileProjectSelection(current, nextContext)).toEqual({
contextKey: "host:route-project",
projectKey: routeProject.projectKey,
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:",
projectKey: initialProject.projectKey,
project: initialProject,
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:",
projectKey: hydratedProject.projectKey,
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:",
projectKey: remembered.projectKey,
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: "host:",
projectKey: manual.projectKey,
project: manual,
source: "manual",
};
const afterRememberedHydration = context({
initialProject: remembered,
projects: [manual, remembered],
lastActiveProject: remembered,
});
expect(reconcileProjectSelection(current, afterRememberedHydration)).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",
routeProjectKey: null,
allowAllProjects: false,
}),
initialProject: fallback,
projects: [fallback, remembered],
}),
);
const afterCapabilityHydration = context({
contextKey: createProjectSelectionContextKey({
selectedServerId: "host",
routeProjectKey: null,
allowAllProjects: true,
}),
initialProject: remembered,
projects: [fallback, remembered],
});
expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({
contextKey: "host:all-projects:",
projectKey: remembered.projectKey,
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({
selectedServerId: "host",
routeProjectKey: null,
}),
projectKey: manual.projectKey,
project: manual,
source: "manual",
};
const afterCapabilityHydration = context({
contextKey: createProjectSelectionContextKey({
selectedServerId: "host",
routeProjectKey: null,
allowAllProjects: true,
}),
manualContextKey: createManualProjectSelectionContextKey({
selectedServerId: "host",
routeProjectKey: 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("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:",
projectKey: fallback.projectKey,
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: "host:route-project",
projectKey: manual.projectKey,
project: manual,
source: "manual",
};
const selectionContext = context({
contextKey: "host:route-project",
initialProject: routeProject,
projects: [manual],
routeProject,
lastActiveProject: remembered,
});
expect(resolveProjectSelection(current, selectionContext)).toEqual(manual);
});
});

View File

@@ -0,0 +1,162 @@
import type { HostProjectListItem } from "@/projects/host-projects";
export type ProjectSelectionSource = "initial" | "manual";
export type InitialProjectSelectionSource = "route" | "lastActive" | "fallback" | null;
export interface ProjectSelection {
contextKey: string;
projectKey: string | null;
project: HostProjectListItem | null;
source: ProjectSelectionSource;
}
export interface ProjectSelectionContext {
contextKey: string;
manualContextKey: string;
initialProject: HostProjectListItem | null;
initialProjectSource: InitialProjectSelectionSource;
projects: HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
shouldPreserveMissingProject: (project: HostProjectListItem) => boolean;
}
export function createProjectSelectionContextKey(input: {
selectedServerId: string;
routeProjectKey: string | null;
allowAllProjects: boolean;
}): string {
const projectScope = input.allowAllProjects ? "all-projects" : "worktree-projects";
return `${input.selectedServerId}:${projectScope}:${input.routeProjectKey ?? ""}`;
}
export function createManualProjectSelectionContextKey(input: {
selectedServerId: string;
routeProjectKey: string | null;
}): string {
return `${input.selectedServerId}:${input.routeProjectKey ?? ""}`;
}
export function createProjectSelection({
contextKey,
initialProject,
}: ProjectSelectionContext): ProjectSelection {
return {
contextKey,
projectKey: initialProject?.projectKey ?? null,
project: initialProject,
source: "initial",
};
}
export function resolveInitialProjectSelectionSource(input: {
initialProject: HostProjectListItem | null;
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
}): InitialProjectSelectionSource {
if (!input.initialProject) {
return null;
}
if (input.routeProject?.projectKey === input.initialProject.projectKey) {
return "route";
}
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
return "lastActive";
}
return "fallback";
}
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
const projectKey = selection.projectKey?.trim() ?? "";
return projectKey || null;
}
function resolveSelectedProjectFromInitialInputs(
projectKey: string,
context: ProjectSelectionContext,
): HostProjectListItem | null {
return (
(context.routeProject?.projectKey === projectKey ? context.routeProject : null) ??
(context.lastActiveProject?.projectKey === projectKey ? context.lastActiveProject : null)
);
}
function refreshSelectionProject(
selection: ProjectSelection,
project: HostProjectListItem,
): ProjectSelection {
if (selection.projectKey === project.projectKey && selection.project === project) {
return selection;
}
return {
...selection,
projectKey: project.projectKey,
project,
};
}
function shouldResetInitialFallbackSelection(
selection: ProjectSelection,
context: ProjectSelectionContext,
): boolean {
if (
selection.source !== "initial" ||
!context.initialProject ||
context.initialProjectSource !== "lastActive"
) {
return false;
}
return selection.projectKey !== context.initialProject.projectKey;
}
export function resolveProjectSelection(
selection: ProjectSelection,
context: ProjectSelectionContext,
): HostProjectListItem | null {
const projectKey = resolveProjectSelectionKey(selection);
if (!projectKey) {
return null;
}
const selectableProject = context.projects.find((project) => project.projectKey === projectKey);
if (selectableProject) {
return selectableProject;
}
if (
selection.project?.projectKey === projectKey &&
context.shouldPreserveMissingProject(selection.project)
) {
return selection.project;
}
if (selection.source !== "manual") {
return resolveSelectedProjectFromInitialInputs(projectKey, context);
}
return null;
}
export function reconcileProjectSelection(
current: ProjectSelection,
context: ProjectSelectionContext,
): ProjectSelection {
const initialSelection = createProjectSelection(context);
const currentContextKey =
current.source === "manual" ? context.manualContextKey : context.contextKey;
if (current.contextKey !== currentContextKey) {
return initialSelection;
}
if (shouldResetInitialFallbackSelection(current, context)) {
return initialSelection;
}
const resolvedProject = resolveProjectSelection(current, context);
if (resolvedProject) {
return refreshSelectionProject(current, resolvedProject);
}
return initialSelection;
}