Skip to content
Source
Sign inSign up

projectGrouping.ts

401 lines13.7 KB

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>;
20​
21export type ProjectGroupingMode = SidebarProjectGroupingMode;
22​
23export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings {
24 return {
25 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
26 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
27 };
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;
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("\\", "/");
70​
71export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string {
72 return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`;
74​
75export function derivePhysicalProjectKey(
76 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
77): string {
78 return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot);
80​
81function deriveProjectGroupingOverrideKey(
82 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
83): string {
84 return derivePhysicalProjectKey(project);
86​
87export function getProjectOrderKey(
88 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
89): string {
90 return derivePhysicalProjectKey(project);
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 );
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}`;
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 );
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 });
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;
200​
201export function deriveProjectGroupLabel(input: {
202 readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;
203 readonly members: ReadonlyArray<
204 Pick<EnvironmentProject, "title" | "hasCustomTitle" | "repositoryIdentity">
205 >;
206}): string {
207 const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));
208 const sharedDisplayNames = uniqueNonEmptyValues(
209 input.members.map((member) =>
210 member.repositoryIdentity ? repositoryGroupingDisplayNameOf(member.repositoryIdentity) : null,
211 ),
212 );
213 const sharedRepositoryNames = uniqueNonEmptyValues(
214 input.members.map((member) => member.repositoryIdentity?.name),
215 );
216 const sharedTitle = sharedTitles[0];
217 if (
218 sharedTitles.length === 1 &&
219 sharedTitle !== undefined &&
220 (input.members.some((member) => member.hasCustomTitle === true) ||
221 (!sharedDisplayNames.includes(sharedTitle) && !sharedRepositoryNames.includes(sharedTitle)))
222 ) {
223 return sharedTitle;
224 }
225 if (sharedDisplayNames.length === 1) {
226 return sharedDisplayNames[0]!;
227 }
228​
229 if (sharedRepositoryNames.length === 1) {
230 return sharedRepositoryNames[0]!;
231 }
232​
233 return input.representative.title;
235​
236export interface ProjectGroupMember<TProject extends EnvironmentProject = EnvironmentProject> {
237 readonly physicalProjectKey: string;
238 readonly project: TProject;
240​
241export interface ProjectGroup<TProject extends EnvironmentProject = EnvironmentProject> {
242 readonly key: string;
243 readonly label: string;
244 readonly representative: TProject;
245 readonly members: ReadonlyArray<ProjectGroupMember<TProject>>;
246 readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef>;
247 /** Clients write every member together. When members still disagree, the project stays visible. */
248 readonly pinned: boolean;
249 readonly archived: boolean;
251​
252/** The groups a project picker offers: archived groups left out, pinned ones first. */
253export function pickableProjectGroups<
254 TGroup extends { readonly pinned: boolean; readonly archived: boolean },
255>(groups: ReadonlyArray<TGroup>): TGroup[] {
256 const open = groups.filter((group) => !group.archived);
257 return [...open.filter((group) => group.pinned), ...open.filter((group) => !group.pinned)];
259​
260function projectFreshnessTime(project: EnvironmentProject): number {
261 const updatedAtTime = Date.parse(project.updatedAt);
262 if (Number.isFinite(updatedAtTime)) {
263 return updatedAtTime;
264 }
265 const createdAtTime = Date.parse(project.createdAt);
266 return Number.isFinite(createdAtTime) ? createdAtTime : 0;
268​
269function shouldReplacePhysicalProjectWinner<TProject extends EnvironmentProject>(
270 existing: TProject,
271 candidate: TProject,
272): boolean {
273 const freshnessDelta = projectFreshnessTime(candidate) - projectFreshnessTime(existing);
274 return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > existing.id);
276​
277function selectProjectIdentitySource<TProject extends EnvironmentProject>(
278 projects: ReadonlyArray<TProject>,
279 winner: TProject,
280): TProject {
281 if (winner.repositoryIdentity !== null) {
282 return winner;
283 }
284​
285 let freshestIdentifiedProject: TProject | null = null;
286 for (const project of projects) {
287 if (project.repositoryIdentity === null) {
288 continue;
289 }
290 if (
291 freshestIdentifiedProject === null ||
292 shouldReplacePhysicalProjectWinner(freshestIdentifiedProject, project)
293 ) {
294 freshestIdentifiedProject = project;
295 }
296 }
297 return freshestIdentifiedProject ?? winner;
299​
300export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
301 readonly projects: ReadonlyArray<TProject>;
302 readonly settings: ProjectGroupingSettings;
303 readonly preferredEnvironmentId?: EnvironmentId | null;
304 readonly groupScratchProjects?: boolean;
305}): ReadonlyArray<ProjectGroup<TProject>> {
306 const projectsByPhysicalKey = new Map<string, TProject[]>();
307 for (const project of input.projects) {
308 const physicalProjectKey = derivePhysicalProjectKey(project);
309 const existing = projectsByPhysicalKey.get(physicalProjectKey);
310 if (existing) {
311 existing.push(project);
312 } else {
313 projectsByPhysicalKey.set(physicalProjectKey, [project]);
314 }
315 }
316​
317 const logicalKeyByPhysicalKey = new Map<string, string>();
318 const groupedMembers = new Map<string, ProjectGroupMember<TProject>[]>();
319 for (const [physicalProjectKey, physicalProjects] of projectsByPhysicalKey) {
320 const winner = physicalProjects.reduce((current, candidate) =>
321 shouldReplacePhysicalProjectWinner(current, candidate) ? candidate : current,
322 );
323 const identitySource = selectProjectIdentitySource(physicalProjects, winner);
324 const logicalKey = deriveLogicalProjectKey(identitySource, {
325 groupingMode: resolveProjectGroupingMode(winner, input.settings),
326 groupScratchProjects: input.groupScratchProjects ?? false,
327 });
328 logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey);
329 const member = { physicalProjectKey, project: winner };
330 const existing = groupedMembers.get(logicalKey);
331 if (existing) {
332 existing.push(member);
333 } else {
334 groupedMembers.set(logicalKey, [member]);
335 }
336 }
337​
338 const projectRefsByLogicalKey = new Map<string, ScopedProjectRef[]>();
339 const seenProjectRefs = new Set<string>();
340 for (const project of input.projects) {
341 const physicalProjectKey = derivePhysicalProjectKey(project);
342 const logicalKey =
343 logicalKeyByPhysicalKey.get(physicalProjectKey) ??
344 deriveLogicalProjectKeyFromSettings(project, input.settings, {
345 groupScratchProjects: input.groupScratchProjects ?? false,
346 });
347 const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
348 if (seenProjectRefs.has(projectRefKey)) continue;
349 seenProjectRefs.add(projectRefKey);
350 const projectRef = scopeProjectRef(project.environmentId, project.id);
351 const existing = projectRefsByLogicalKey.get(logicalKey);
352 if (existing) {
353 existing.push(projectRef);
354 } else {
355 projectRefsByLogicalKey.set(logicalKey, [projectRef]);
356 }
357 }
358​
359 const preferredEnvironmentId = input.preferredEnvironmentId ?? null;
360 return Array.from(groupedMembers, ([key, members]) => {
361 const representative =
362 (preferredEnvironmentId
363 ? members.find((member) => member.project.environmentId === preferredEnvironmentId)?.project
364 : null) ?? members[0]!.project;
365 return {
366 key,
367 label:
368 input.groupScratchProjects && representative.isScratch
369 ? "No project"
370 : members.length > 1
371 ? deriveProjectGroupLabel({
372 representative,
373 members: members.map((member) => member.project),
374 })
375 : representative.title,
376 representative,
377 members,
378 memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [],
379 pinned: members.some((member) => member.project.pinnedAt != null),
380 archived: members.every((member) => member.project.archivedAt != null),
381 };
382 });
384​
385export function sortProjectsByGroupOrder<
386 TProject extends { readonly id: string; readonly environmentId: string },
387>(
388 projects: ReadonlyArray<TProject>,
389 groups: ReadonlyArray<{ readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef> }>,
390): TProject[] {
391 const rankByProject = new Map<string, number>();
392 groups.forEach((group, rank) => {
393 for (const ref of group.memberProjectRefs) {
394 const key = `${ref.environmentId}\0${ref.projectId}`;
395 if (!rankByProject.has(key)) rankByProject.set(key, rank);
396 }
397 });
398 const rankOf = (project: TProject) =>
399 rankByProject.get(`${project.environmentId}\0${project.id}`) ?? groups.length;
400 return [...projects].sort((left, right) => rankOf(left) - rankOf(right));