diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index 550d4df3b..6bd79af19 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -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, }) => { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index d7cf1161c..40c7e6114 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -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(); - 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; - selectedProjectOptionId: string; - projectTriggerLabel: string; - handleSelectProjectOption: (id: string) => void; -} - -function useNewWorkspaceProjectPicker({ - selectedServerId, - projects, - routeProject, - lastActiveProject, - displayName: displayNameProp, - allowAllProjects, -}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState { - const [manualProjectKey, setManualProjectKey] = useState(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, }); diff --git a/packages/app/src/screens/new-workspace/project-picker.ts b/packages/app/src/screens/new-workspace/project-picker.ts new file mode 100644 index 000000000..bb373f394 --- /dev/null +++ b/packages/app/src/screens/new-workspace/project-picker.ts @@ -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; + 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(); + 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( + () => ({ + 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(() => + 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, + }; +} diff --git a/packages/app/src/screens/new-workspace/project-selection.test.ts b/packages/app/src/screens/new-workspace/project-selection.test.ts new file mode 100644 index 000000000..e9dae3258 --- /dev/null +++ b/packages/app/src/screens/new-workspace/project-selection.test.ts @@ -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 & { + 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); + }); +}); diff --git a/packages/app/src/screens/new-workspace/project-selection.ts b/packages/app/src/screens/new-workspace/project-selection.ts new file mode 100644 index 000000000..58ed75ae4 --- /dev/null +++ b/packages/app/src/screens/new-workspace/project-selection.ts @@ -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; +}