| 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | |
| 3 | import type { |
| 4 | SidebarProjectGroupMember, |
| 5 | SidebarProjectSnapshot, |
| 6 | } from "../../sidebarProjectGrouping"; |
| 7 | import type { EnvironmentPresentation } from "../../state/environments"; |
| 8 | |
| 9 | /** |
| 10 | * Two axes. `machine` narrows the environment axis (absent = all |
| 11 | * environments); `project` and `checkout` narrow the project axis (absent = |
| 12 | * environment defaults). Device-local preferences are not a scope: they |
| 13 | * render regardless of the selection because they never touch a server. |
| 14 | */ |
| 15 | export interface SettingsScopeSearch { |
| 16 | project?: string | undefined; |
| 17 | machine?: string | undefined; |
| 18 | checkout?: string | undefined; |
| 19 | } |
| 20 | |
| 21 | type ScopeTargets = { |
| 22 | label: string; |
| 23 | members: readonly SidebarProjectGroupMember[]; |
| 24 | environmentIds: readonly EnvironmentId[]; |
| 25 | }; |
| 26 | |
| 27 | export type ResolvedSettingsScope = ScopeTargets & |
| 28 | ( |
| 29 | | { kind: "all" } |
| 30 | | { kind: "environment"; environmentId: EnvironmentId } |
| 31 | | { |
| 32 | kind: "project"; |
| 33 | group: SidebarProjectSnapshot; |
| 34 | environmentId: EnvironmentId | null; |
| 35 | } |
| 36 | | { |
| 37 | kind: "checkout"; |
| 38 | group: SidebarProjectSnapshot; |
| 39 | checkout: SidebarProjectGroupMember; |
| 40 | environmentId: EnvironmentId; |
| 41 | } |
| 42 | | { |
| 43 | kind: "unavailable"; |
| 44 | reason: "project-required" | "project-missing" | "environment-missing" | "checkout-missing"; |
| 45 | message: string; |
| 46 | } |
| 47 | ); |
| 48 | |
| 49 | /** Stale IDs remain visible to the resolver so a removed target reads as unavailable, not as "all". */ |
| 50 | export function validateSettingsScopeSearch(raw: Record<string, unknown>): SettingsScopeSearch { |
| 51 | const stringValue = (value: unknown) => |
| 52 | typeof value === "string" && value.trim().length > 0 ? value : undefined; |
| 53 | const project = stringValue(raw.project); |
| 54 | const machine = stringValue(raw.machine); |
| 55 | const checkout = stringValue(raw.checkout); |
| 56 | return { |
| 57 | ...(project === undefined ? {} : { project }), |
| 58 | ...(machine === undefined ? {} : { machine }), |
| 59 | ...(checkout === undefined ? {} : { checkout }), |
| 60 | }; |
| 61 | } |
| 62 | |
| 63 | /** Resolves only existing targets. An unavailable selection never broadens a subsequent write. */ |
| 64 | export function resolveSettingsScope( |
| 65 | search: SettingsScopeSearch, |
| 66 | groups: readonly SidebarProjectSnapshot[], |
| 67 | environments: readonly Pick<EnvironmentPresentation, "environmentId" | "label">[], |
| 68 | ): ResolvedSettingsScope { |
| 69 | const unavailable = ( |
| 70 | reason: Extract<ResolvedSettingsScope, { kind: "unavailable" }>["reason"], |
| 71 | message: string, |
| 72 | ): ResolvedSettingsScope => ({ |
| 73 | kind: "unavailable", |
| 74 | reason, |
| 75 | label: "Unavailable selection", |
| 76 | message, |
| 77 | members: [], |
| 78 | environmentIds: [], |
| 79 | }); |
| 80 | |
| 81 | if (search.checkout && !search.project) { |
| 82 | return unavailable("project-required", "Select a project to choose one of its checkouts."); |
| 83 | } |
| 84 | |
| 85 | const environment = environments.find((candidate) => candidate.environmentId === search.machine); |
| 86 | if (search.machine && !environment) { |
| 87 | return unavailable("environment-missing", "This environment is no longer available."); |
| 88 | } |
| 89 | |
| 90 | if (search.project) { |
| 91 | const group = groups.find((candidate) => candidate.projectKey === search.project); |
| 92 | if (!group) return unavailable("project-missing", "This project is no longer available."); |
| 93 | const members = group.memberProjects.filter( |
| 94 | (member) => |
| 95 | (search.machine === undefined || member.environmentId === search.machine) && |
| 96 | (search.checkout === undefined || member.physicalProjectKey === search.checkout), |
| 97 | ); |
| 98 | if (members.length === 0) { |
| 99 | return unavailable( |
| 100 | "checkout-missing", |
| 101 | search.checkout |
| 102 | ? "This checkout is no longer available in the selected project and environment." |
| 103 | : "This project has no checkout on this environment.", |
| 104 | ); |
| 105 | } |
| 106 | if (search.checkout) { |
| 107 | const checkout = members[0]!; |
| 108 | const checkoutEnvironment = environments.find( |
| 109 | (candidate) => candidate.environmentId === checkout.environmentId, |
| 110 | ); |
| 111 | if (!checkoutEnvironment) { |
| 112 | return unavailable( |
| 113 | "environment-missing", |
| 114 | "This checkout's environment is no longer available.", |
| 115 | ); |
| 116 | } |
| 117 | const sharesEnvironment = group.memberProjects.some( |
| 118 | (member) => |
| 119 | member.environmentId === checkout.environmentId && |
| 120 | member.physicalProjectKey !== checkout.physicalProjectKey, |
| 121 | ); |
| 122 | return { |
| 123 | kind: "checkout", |
| 124 | group, |
| 125 | checkout, |
| 126 | environmentId: checkout.environmentId, |
| 127 | label: `${group.displayName} / ${checkoutEnvironment.label}${sharesEnvironment ? ` · ${checkout.workspaceRoot}` : ""}`, |
| 128 | members, |
| 129 | environmentIds: [checkout.environmentId], |
| 130 | }; |
| 131 | } |
| 132 | return { |
| 133 | kind: "project", |
| 134 | group, |
| 135 | environmentId: environment?.environmentId ?? null, |
| 136 | label: `${group.displayName} / ${environment?.label ?? "All checkouts"}`, |
| 137 | members, |
| 138 | environmentIds: [...new Set(members.map((member) => member.environmentId))], |
| 139 | }; |
| 140 | } |
| 141 | if (environment) { |
| 142 | return { |
| 143 | kind: "environment", |
| 144 | environmentId: environment.environmentId, |
| 145 | label: environment.label, |
| 146 | members: [], |
| 147 | environmentIds: [environment.environmentId], |
| 148 | }; |
| 149 | } |
| 150 | return { |
| 151 | kind: "all", |
| 152 | label: "All environments", |
| 153 | members: [], |
| 154 | environmentIds: environments.map((candidate) => candidate.environmentId), |
| 155 | }; |
| 156 | } |