1import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts";
2import {
3 buildProjectGroups,
4 derivePhysicalProjectKey,
5 type ProjectGroupingSettings,
6} from "./logicalProject";
7import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping";
8import type { Project } from "./types";
9
10export type EnvironmentPresence = "local-only" | "remote-only" | "mixed";
11
12export interface SidebarProjectGroupMember extends Project {
13 physicalProjectKey: string;
14 environmentLabel: string | null;
15}
16
17export interface SidebarProjectSnapshot extends Project {
18 projectKey: string;
19 displayName: string;
20 groupedProjectCount: number;
21 environmentPresence: EnvironmentPresence;
22 // True iff every non-primary member of this group lives in a
23 // desktop-local environment. The sidebar uses this
24 // to differentiate "lives on this machine but in a sandbox" from
25 // "lives on a real remote" so the project header can pick a
26 // local-device treatment instead of the generic remote treatment.
27 allRemoteMembersAreDesktopLocal: boolean;
28 allRemoteMembersAreWsl: boolean;
29 memberProjects: readonly SidebarProjectGroupMember[];
30 memberProjectRefs: readonly ScopedProjectRef[];
31 remoteEnvironmentLabels: readonly string[];
32}
33
34export function projectGroupsSpanEnvironments(
35 groups: ReadonlyArray<Pick<SidebarProjectSnapshot, "memberProjects">>,
36): boolean {
37 const environmentIds = new Set<EnvironmentId>();
38 for (const group of groups) {
39 for (const member of group.memberProjects) {
40 environmentIds.add(member.environmentId);
41 if (environmentIds.size > 1) return true;
42 }
43 }
44 return false;
45}
46
47export interface SidebarProjectPickerEntry {
48 group: SidebarProjectSnapshot;
49 targetProject: SidebarProjectGroupMember;
50 isPreferred: boolean;
51}
52
53/** A merged filter still opens settings for the chosen physical project. */
54export function getSidebarProjectSettingsKey(
55 group: SidebarProjectSnapshot,
56 settings: ProjectGroupingSettings,
57 projects: ReadonlyArray<Project>,
58 projectRef?: ScopedProjectRef,
59): string {
60 if (!group.isScratch) return group.projectKey;
61 const project =
62 group.memberProjects.find(
63 (member) =>
64 member.environmentId === projectRef?.environmentId && member.id === projectRef.projectId,
65 ) ??
66 group.memberProjects.find((member) => member.environmentId === projectRef?.environmentId) ??
67 group;
68 return (
69 buildPhysicalToLogicalProjectKeyMap({ projects, settings, primaryEnvironmentId: null }).get(
70 derivePhysicalProjectKey(project),
71 ) ?? group.projectKey
72 );
73}
74
75/** Retains a saved physical scratch scope when its environment config coalesces the group. */
76export function resolveSidebarProjectScopeKey(input: {
77 readonly groups: ReadonlyArray<SidebarProjectSnapshot>;
78 readonly key: string | null;
79 readonly settings?: ProjectGroupingSettings;
80 readonly canClearMissingScope?: boolean;
81}): string | null {
82 if (input.key === null) return null;
83 const group =
84 input.groups.find((candidate) => candidate.projectKey === input.key) ??
85 input.groups.find(
86 (candidate) =>
87 candidate.isScratch &&
88 resolveScratchProjectScopeKey(input.key, candidate.memberProjects, input.settings) ===
89 candidate.projectKey,
90 );
91 return group?.projectKey ?? (input.canClearMissingScope ? null : input.key);
92}
93
94export function buildPhysicalToLogicalProjectKeyMap(input: {
95 projects: ReadonlyArray<Project>;
96 settings: ProjectGroupingSettings;
97 primaryEnvironmentId: EnvironmentId | null;
98}): Map<string, string> {
99 const mapping = new Map<string, string>();
100 const groups = buildProjectGroups({
101 projects: input.projects,
102 settings: input.settings,
103 preferredEnvironmentId: input.primaryEnvironmentId,
104 });
105 for (const group of groups) {
106 for (const member of group.members) {
107 mapping.set(member.physicalProjectKey, group.key);
108 }
109 }
110 return mapping;
111}
112
113export function buildSidebarProjectSnapshots(input: {
114 projects: ReadonlyArray<Project>;
115 settings: ProjectGroupingSettings;
116 groupScratchProjects?: boolean;
117 primaryEnvironmentId: EnvironmentId | null;
118 resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null;
119 // Returns true when an env id maps to a desktop-local saved-env
120 // record. Defaults to "false for every
121 // env" so callers that don't care about the distinction get the
122 // legacy behavior.
123 isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean;
124 isWslEnvironment?: (environmentId: EnvironmentId) => boolean;
125}): SidebarProjectSnapshot[] {
126 return buildProjectGroups({
127 projects: input.projects,
128 groupScratchProjects: input.groupScratchProjects ?? false,
129 settings: input.settings,
130 preferredEnvironmentId: input.primaryEnvironmentId,
131 }).map((group): SidebarProjectSnapshot => {
132 const members = group.members.map(
133 ({ physicalProjectKey, project }): SidebarProjectGroupMember => ({
134 ...project,
135 physicalProjectKey,
136 environmentLabel: input.resolveEnvironmentLabel(project.environmentId),
137 }),
138 );
139 const representative =
140 members.find(
141 (member) =>
142 member.environmentId === group.representative.environmentId &&
143 member.id === group.representative.id,
144 ) ?? members[0]!;
145
146 const hasLocal =
147 input.primaryEnvironmentId !== null &&
148 members.some((member) => member.environmentId === input.primaryEnvironmentId);
149 const hasRemote =
150 input.primaryEnvironmentId !== null
151 ? members.some((member) => member.environmentId !== input.primaryEnvironmentId)
152 : false;
153 const remoteMembers = members.filter(
154 (member) =>
155 input.primaryEnvironmentId !== null && member.environmentId !== input.primaryEnvironmentId,
156 );
157 const remoteEnvironmentLabels = remoteMembers
158 .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : []))
159 .filter((label, index, labels) => labels.indexOf(label) === index);
160 const isDesktopLocal = input.isDesktopLocalEnvironment ?? (() => false);
161 const isWsl = input.isWslEnvironment ?? (() => false);
162 const allRemoteMembersAreDesktopLocal =
163 remoteMembers.length > 0 &&
164 remoteMembers.every((member) => isDesktopLocal(member.environmentId));
165 const allRemoteMembersAreWsl =
166 remoteMembers.length > 0 && remoteMembers.every((member) => isWsl(member.environmentId));
167
168 return {
169 ...representative,
170 projectKey: group.key,
171 displayName: group.label,
172 groupedProjectCount: members.length,
173 environmentPresence:
174 hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
175 allRemoteMembersAreDesktopLocal,
176 allRemoteMembersAreWsl,
177 memberProjects: members,
178 memberProjectRefs: group.memberProjectRefs,
179 remoteEnvironmentLabels,
180 };
181 });
182}
183
184export function buildSidebarProjectPickerEntries(input: {
185 groups: ReadonlyArray<SidebarProjectSnapshot>;
186 preferredProjectRef: ScopedProjectRef | null;
187}) {
188 const preferredProjectRef = input.preferredProjectRef;
189 const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => {
190 const isPreferred = preferredProjectRef
191 ? group.memberProjectRefs.some(
192 (projectRef) =>
193 projectRef.environmentId === preferredProjectRef.environmentId &&
194 projectRef.projectId === preferredProjectRef.projectId,
195 )
196 : false;
197 const preferredProject = preferredProjectRef
198 ? (group.memberProjects.find(
199 (project) =>
200 project.environmentId === preferredProjectRef.environmentId &&
201 project.id === preferredProjectRef.projectId,
202 ) ??
203 group.memberProjects.find(
204 (project) => project.environmentId === preferredProjectRef.environmentId,
205 ))
206 : null;
207 const targetProject =
208 preferredProject ??
209 group.memberProjects.find(
210 (project) => project.environmentId === group.environmentId && project.id === group.id,
211 ) ??
212 group.memberProjects[0];
213 if (!targetProject) return [];
214
215 return [{ group, targetProject, isPreferred }];
216 });
217 const preferredIndex = entries.findIndex((entry) => entry.isPreferred);
218 if (preferredIndex <= 0) return entries;
219
220 return [
221 entries[preferredIndex]!,
222 ...entries.slice(0, preferredIndex),
223 ...entries.slice(preferredIndex + 1),
224 ];
225}