1import type { EnvironmentId } from "@t3tools/contracts";
2
3import type {
4 SidebarProjectGroupMember,
5 SidebarProjectSnapshot,
6} from "../../sidebarProjectGrouping";
7import 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 */
15export interface SettingsScopeSearch {
16 project?: string | undefined;
17 machine?: string | undefined;
18 checkout?: string | undefined;
19}
20
21type ScopeTargets = {
22 label: string;
23 members: readonly SidebarProjectGroupMember[];
24 environmentIds: readonly EnvironmentId[];
25};
26
27export 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". */
50export 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. */
64export 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}