Open History to see the last commit
1import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";
2import {
3 repositoryGroupingDisplayNameOf,
4 repositoryGroupingKeyOf,
5 type EnvironmentId,
6 type ScopedProjectRef,
7 type SidebarProjectGroupingMode,
8} from "@t3tools/contracts";
9import type { ClientSettings } from "@t3tools/contracts/settings";
10
11import type { EnvironmentProject } from "./models.ts";
12import { normalizeProjectPathForComparison } from "./projects.ts";
13
14const SCRATCH_PROJECT_SCOPE_KEY = "\0no-project";
15
16export interface ProjectGroupingSettings {
17 readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode;
18 readonly sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>;
19}
20
21export type ProjectGroupingMode = SidebarProjectGroupingMode;
22
23export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings {
24 return {
25 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
26 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
27 };
28}
29
30function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): string[] {
31 const seen = new Set<string>();
32 const unique: string[] = [];
33 for (const value of values) {
34 const trimmed = value?.trim();
35 if (!trimmed || seen.has(trimmed)) {
36 continue;
37 }
38 seen.add(trimmed);
39 unique.push(trimmed);
40 }
41 return unique;
42}
43
44function deriveRepositoryRelativeProjectPath(
45 project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
46): string | null {
47 const rootPath = project.repositoryIdentity?.rootPath?.trim();
48 if (!rootPath) {
49 return null;
50 }
51
52 const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot);
53 const normalizedRootPath = normalizeProjectPathForComparison(rootPath);
54 if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) {
55 return null;
56 }
57
58 if (normalizedProjectPath === normalizedRootPath) {
59 return "";
60 }
61
62 const separator = normalizedRootPath.includes("\\") ? "\\" : "/";
63 const rootPrefix = `${normalizedRootPath}${separator}`;
64 if (!normalizedProjectPath.startsWith(rootPrefix)) {
65 return null;
66 }
67
68 return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/");
69}
70
71export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string {
72 return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`;
73}
74
75export function derivePhysicalProjectKey(
76 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
77): string {
78 return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot);
79}
80
81function deriveProjectGroupingOverrideKey(
82 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
83): string {
84 return derivePhysicalProjectKey(project);
85}
86
87export function getProjectOrderKey(
88 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
89): string {
90 return derivePhysicalProjectKey(project);
91}
92
93function resolveProjectGroupingMode(
94 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
95 settings: ProjectGroupingSettings,
96): SidebarProjectGroupingMode {
97 return (
98 settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ??
99 settings.sidebarProjectGroupingMode
100 );
101}
102
103function deriveRepositoryScopedKey(
104 project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
105 groupingMode: SidebarProjectGroupingMode,
106): string | null {
107 const canonicalKey = project.repositoryIdentity
108 ? repositoryGroupingKeyOf(project.repositoryIdentity)
109 : null;
110 // A NUL reserves the scratch key namespace outside valid repository identities.
111 if (!canonicalKey || canonicalKey.includes("\0")) {
112 return null;
113 }
114
115 if (groupingMode === "repository") {
116 return canonicalKey;
117 }
118
119 const relativeProjectPath = deriveRepositoryRelativeProjectPath(project);
120 if (relativeProjectPath === null) {
121 return canonicalKey;
122 }
123
124 return relativeProjectPath.length === 0
125 ? canonicalKey
126 : `${canonicalKey}::${relativeProjectPath}`;
127}
128
129function deriveLogicalProjectKey(
130 project: Pick<
131 EnvironmentProject,
132 "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch"
133 >,
134 options?: {
135 readonly groupingMode?: SidebarProjectGroupingMode;
136 readonly groupScratchProjects?: boolean;
137 },
138): string {
139 if (options?.groupScratchProjects && project.isScratch) return SCRATCH_PROJECT_SCOPE_KEY;
140 const groupingMode = options?.groupingMode ?? "repository";
141 if (groupingMode === "separate") {
142 return derivePhysicalProjectKey(project);
143 }
144
145 return (
146 deriveRepositoryScopedKey(project, groupingMode) ??
147 derivePhysicalProjectKey(project) ??
148 scopedProjectKey(scopeProjectRef(project.environmentId, project.id))
149 );
150}
151
152export function deriveLogicalProjectKeyFromSettings(
153 project: Pick<
154 EnvironmentProject,
155 "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch"
156 >,
157 settings: ProjectGroupingSettings,
158 options?: { readonly groupScratchProjects?: boolean },
159): string {
160 return deriveLogicalProjectKey(project, {
161 groupingMode: resolveProjectGroupingMode(project, settings),
162 groupScratchProjects: options?.groupScratchProjects ?? false,
163 });
164}
165
166export function resolveScratchProjectScopeKey(
167 key: string | null,
168 projects: ReadonlyArray<
169 Pick<
170 EnvironmentProject,
171 "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch"
172 >
173 >,
174 settings: ProjectGroupingSettings = {
175 sidebarProjectGroupingMode: "repository",
176 sidebarProjectGroupingOverrides: {},
177 },
178): string | null {
179 if (key === null || key === SCRATCH_PROJECT_SCOPE_KEY) return key;
180 if (
181 projects.some(
182 (project) =>
183 deriveLogicalProjectKeyFromSettings(project, settings, { groupScratchProjects: true }) ===
184 key,
185 )
186 ) {
187 return key;
188 }
189 return projects.some((project) => {
190 if (!project.isScratch) return false;
191 const mode = resolveProjectGroupingMode(project, settings);
192 return (
193 derivePhysicalProjectKey(project) === key ||
194 (mode !== "separate" && deriveRepositoryScopedKey(project, mode) === key)
195 );
196 })
197 ? SCRATCH_PROJECT_SCOPE_KEY
198 : key;
199}
200
201export function deriveProjectGroupLabel(input: {
202 readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;
203 readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;
204}): string {
205 const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));
206 const sharedDisplayNames = uniqueNonEmptyValues(
207 input.members.map((member) =>
208 member.repositoryIdentity ? repositoryGroupingDisplayNameOf(member.repositoryIdentity) : null,
209 ),
210 );
211 const sharedRepositoryNames = uniqueNonEmptyValues(
212 input.members.map((member) => member.repositoryIdentity?.name),
213 );
214 const sharedTitle = sharedTitles[0];
215 if (
216 sharedTitles.length === 1 &&
217 sharedTitle !== undefined &&
218 !sharedDisplayNames.includes(sharedTitle) &&
219 !sharedRepositoryNames.includes(sharedTitle)
220 ) {
221 return sharedTitle;
222 }
223 if (sharedDisplayNames.length === 1) {
224 return sharedDisplayNames[0]!;
225 }
226
227 if (sharedRepositoryNames.length === 1) {
228 return sharedRepositoryNames[0]!;
229 }
230
231 return input.representative.title;
232}
233
234export interface ProjectGroupMember<TProject extends EnvironmentProject = EnvironmentProject> {
235 readonly physicalProjectKey: string;
236 readonly project: TProject;
237}
238
239export interface ProjectGroup<TProject extends EnvironmentProject = EnvironmentProject> {
240 readonly key: string;
241 readonly label: string;
242 readonly representative: TProject;
243 readonly members: ReadonlyArray<ProjectGroupMember<TProject>>;
244 readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef>;
245 /** Clients write every member together. When members still disagree, the project stays visible. */
246 readonly pinned: boolean;
247 readonly archived: boolean;
248}
249
250/** The groups a project picker offers: archived groups left out, pinned ones first. */
251export function pickableProjectGroups<
252 TGroup extends { readonly pinned: boolean; readonly archived: boolean },
253>(groups: ReadonlyArray<TGroup>): TGroup[] {
254 const open = groups.filter((group) => !group.archived);
255 return [...open.filter((group) => group.pinned), ...open.filter((group) => !group.pinned)];
256}
257
258function projectFreshnessTime(project: EnvironmentProject): number {
259 const updatedAtTime = Date.parse(project.updatedAt);
260 if (Number.isFinite(updatedAtTime)) {
261 return updatedAtTime;
262 }
263 const createdAtTime = Date.parse(project.createdAt);
264 return Number.isFinite(createdAtTime) ? createdAtTime : 0;
265}
266
267function shouldReplacePhysicalProjectWinner<TProject extends EnvironmentProject>(
268 existing: TProject,
269 candidate: TProject,
270): boolean {
271 const freshnessDelta = projectFreshnessTime(candidate) - projectFreshnessTime(existing);
272 return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > existing.id);
273}
274
275function selectProjectIdentitySource<TProject extends EnvironmentProject>(
276 projects: ReadonlyArray<TProject>,
277 winner: TProject,
278): TProject {
279 if (winner.repositoryIdentity !== null) {
280 return winner;
281 }
282
283 let freshestIdentifiedProject: TProject | null = null;
284 for (const project of projects) {
285 if (project.repositoryIdentity === null) {
286 continue;
287 }
288 if (
289 freshestIdentifiedProject === null ||
290 shouldReplacePhysicalProjectWinner(freshestIdentifiedProject, project)
291 ) {
292 freshestIdentifiedProject = project;
293 }
294 }
295 return freshestIdentifiedProject ?? winner;
296}
297
298export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
299 readonly projects: ReadonlyArray<TProject>;
300 readonly settings: ProjectGroupingSettings;
301 readonly preferredEnvironmentId?: EnvironmentId | null;
302 readonly groupScratchProjects?: boolean;
303}): ReadonlyArray<ProjectGroup<TProject>> {
304 const projectsByPhysicalKey = new Map<string, TProject[]>();
305 for (const project of input.projects) {
306 const physicalProjectKey = derivePhysicalProjectKey(project);
307 const existing = projectsByPhysicalKey.get(physicalProjectKey);
308 if (existing) {
309 existing.push(project);
310 } else {
311 projectsByPhysicalKey.set(physicalProjectKey, [project]);
312 }
313 }
314
315 const logicalKeyByPhysicalKey = new Map<string, string>();
316 const groupedMembers = new Map<string, ProjectGroupMember<TProject>[]>();
317 for (const [physicalProjectKey, physicalProjects] of projectsByPhysicalKey) {
318 const winner = physicalProjects.reduce((current, candidate) =>
319 shouldReplacePhysicalProjectWinner(current, candidate) ? candidate : current,
320 );
321 const identitySource = selectProjectIdentitySource(physicalProjects, winner);
322 const logicalKey = deriveLogicalProjectKey(identitySource, {
323 groupingMode: resolveProjectGroupingMode(winner, input.settings),
324 groupScratchProjects: input.groupScratchProjects ?? false,
325 });
326 logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey);
327 const member = { physicalProjectKey, project: winner };
328 const existing = groupedMembers.get(logicalKey);
329 if (existing) {
330 existing.push(member);
331 } else {
332 groupedMembers.set(logicalKey, [member]);
333 }
334 }
335
336 const projectRefsByLogicalKey = new Map<string, ScopedProjectRef[]>();
337 const seenProjectRefs = new Set<string>();
338 for (const project of input.projects) {
339 const physicalProjectKey = derivePhysicalProjectKey(project);
340 const logicalKey =
341 logicalKeyByPhysicalKey.get(physicalProjectKey) ??
342 deriveLogicalProjectKeyFromSettings(project, input.settings, {
343 groupScratchProjects: input.groupScratchProjects ?? false,
344 });
345 const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
346 if (seenProjectRefs.has(projectRefKey)) continue;
347 seenProjectRefs.add(projectRefKey);
348 const projectRef = scopeProjectRef(project.environmentId, project.id);
349 const existing = projectRefsByLogicalKey.get(logicalKey);
350 if (existing) {
351 existing.push(projectRef);
352 } else {
353 projectRefsByLogicalKey.set(logicalKey, [projectRef]);
354 }
355 }
356
357 const preferredEnvironmentId = input.preferredEnvironmentId ?? null;
358 return Array.from(groupedMembers, ([key, members]) => {
359 const representative =
360 (preferredEnvironmentId
361 ? members.find((member) => member.project.environmentId === preferredEnvironmentId)?.project
362 : null) ?? members[0]!.project;
363 return {
364 key,
365 label:
366 input.groupScratchProjects && representative.isScratch
367 ? "No project"
368 : members.length > 1
369 ? deriveProjectGroupLabel({
370 representative,
371 members: members.map((member) => member.project),
372 })
373 : representative.title,
374 representative,
375 members,
376 memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [],
377 pinned: members.some((member) => member.project.pinnedAt != null),
378 archived: members.every((member) => member.project.archivedAt != null),
379 };
380 });
381}
382
383export function sortProjectsByGroupOrder<
384 TProject extends { readonly id: string; readonly environmentId: string },
385>(
386 projects: ReadonlyArray<TProject>,
387 groups: ReadonlyArray<{ readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef> }>,
388): TProject[] {
389 const rankByProject = new Map<string, number>();
390 groups.forEach((group, rank) => {
391 for (const ref of group.memberProjectRefs) {
392 const key = `${ref.environmentId}\0${ref.projectId}`;
393 if (!rankByProject.has(key)) rankByProject.set(key, rank);
394 }
395 });
396 const rankOf = (project: TProject) =>
397 rankByProject.get(`${project.environmentId}\0${project.id}`) ?? groups.length;
398 return [...projects].sort((left, right) => rankOf(left) - rankOf(right));
399}