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
81export function 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
93export function 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
129export function 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
166/** Resolves a scratch filter selected before its environment config identified the project. */
167export function resolveScratchProjectScopeKey(
168 key: string | null,
169 projects: ReadonlyArray<
170 Pick<
171 EnvironmentProject,
172 "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch"
173 >
174 >,
175 settings: ProjectGroupingSettings = {
176 sidebarProjectGroupingMode: "repository",
177 sidebarProjectGroupingOverrides: {},
178 },
179): string | null {
180 if (key === null || key === SCRATCH_PROJECT_SCOPE_KEY) return key;
181 if (
182 projects.some(
183 (project) =>
184 deriveLogicalProjectKeyFromSettings(project, settings, { groupScratchProjects: true }) ===
185 key,
186 )
187 ) {
188 return key;
189 }
190 return projects.some((project) => {
191 if (!project.isScratch) return false;
192 const mode = resolveProjectGroupingMode(project, settings);
193 return (
194 derivePhysicalProjectKey(project) === key ||
195 (mode !== "separate" && deriveRepositoryScopedKey(project, mode) === key)
196 );
197 })
198 ? SCRATCH_PROJECT_SCOPE_KEY
199 : key;
200}
201
202export function deriveProjectGroupLabel(input: {
203 readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;
204 readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;
205}): string {
206 const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));
207 const sharedDisplayNames = uniqueNonEmptyValues(
208 input.members.map((member) =>
209 member.repositoryIdentity ? repositoryGroupingDisplayNameOf(member.repositoryIdentity) : null,
210 ),
211 );
212 const sharedRepositoryNames = uniqueNonEmptyValues(
213 input.members.map((member) => member.repositoryIdentity?.name),
214 );
215 const sharedTitle = sharedTitles[0];
216 if (
217 sharedTitles.length === 1 &&
218 sharedTitle !== undefined &&
219 !sharedDisplayNames.includes(sharedTitle) &&
220 !sharedRepositoryNames.includes(sharedTitle)
221 ) {
222 return sharedTitle;
223 }
224 if (sharedDisplayNames.length === 1) {
225 return sharedDisplayNames[0]!;
226 }
227
228 if (sharedRepositoryNames.length === 1) {
229 return sharedRepositoryNames[0]!;
230 }
231
232 return input.representative.title;
233}
234
235export interface ProjectGroupMember<TProject extends EnvironmentProject = EnvironmentProject> {
236 readonly physicalProjectKey: string;
237 readonly project: TProject;
238}
239
240export interface ProjectGroup<TProject extends EnvironmentProject = EnvironmentProject> {
241 readonly key: string;
242 readonly label: string;
243 readonly representative: TProject;
244 readonly members: ReadonlyArray<ProjectGroupMember<TProject>>;
245 readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef>;
246}
247
248function projectFreshnessTime(project: EnvironmentProject): number {
249 const updatedAtTime = Date.parse(project.updatedAt);
250 if (Number.isFinite(updatedAtTime)) {
251 return updatedAtTime;
252 }
253 const createdAtTime = Date.parse(project.createdAt);
254 return Number.isFinite(createdAtTime) ? createdAtTime : 0;
255}
256
257function shouldReplacePhysicalProjectWinner<TProject extends EnvironmentProject>(
258 existing: TProject,
259 candidate: TProject,
260): boolean {
261 const freshnessDelta = projectFreshnessTime(candidate) - projectFreshnessTime(existing);
262 return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > existing.id);
263}
264
265function selectProjectIdentitySource<TProject extends EnvironmentProject>(
266 projects: ReadonlyArray<TProject>,
267 winner: TProject,
268): TProject {
269 if (winner.repositoryIdentity !== null) {
270 return winner;
271 }
272
273 let freshestIdentifiedProject: TProject | null = null;
274 for (const project of projects) {
275 if (project.repositoryIdentity === null) {
276 continue;
277 }
278 if (
279 freshestIdentifiedProject === null ||
280 shouldReplacePhysicalProjectWinner(freshestIdentifiedProject, project)
281 ) {
282 freshestIdentifiedProject = project;
283 }
284 }
285 return freshestIdentifiedProject ?? winner;
286}
287
288/**
289 * Builds logical project groups without losing the physical projects that
290 * remain the actual navigation and task-creation targets.
291 *
292 * Presentation-specific metadata, filtering, and activity sorting stay in
293 * each client. Grouping modes, overrides, physical deduplication, labels, and
294 * member preservation live here so web and mobile cannot drift.
295 */
296export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
297 readonly projects: ReadonlyArray<TProject>;
298 readonly settings: ProjectGroupingSettings;
299 readonly preferredEnvironmentId?: EnvironmentId | null;
300 /** Filter equivalence does not change the default ownership of mutable project settings. */
301 readonly groupScratchProjects?: boolean;
302}): ReadonlyArray<ProjectGroup<TProject>> {
303 const projectsByPhysicalKey = new Map<string, TProject[]>();
304 for (const project of input.projects) {
305 const physicalProjectKey = derivePhysicalProjectKey(project);
306 const existing = projectsByPhysicalKey.get(physicalProjectKey);
307 if (existing) {
308 existing.push(project);
309 } else {
310 projectsByPhysicalKey.set(physicalProjectKey, [project]);
311 }
312 }
313
314 const logicalKeyByPhysicalKey = new Map<string, string>();
315 const groupedMembers = new Map<string, ProjectGroupMember<TProject>[]>();
316 for (const [physicalProjectKey, physicalProjects] of projectsByPhysicalKey) {
317 const winner = physicalProjects.reduce((current, candidate) =>
318 shouldReplacePhysicalProjectWinner(current, candidate) ? candidate : current,
319 );
320 const identitySource = selectProjectIdentitySource(physicalProjects, winner);
321 const logicalKey = deriveLogicalProjectKey(identitySource, {
322 groupingMode: resolveProjectGroupingMode(winner, input.settings),
323 groupScratchProjects: input.groupScratchProjects ?? false,
324 });
325 logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey);
326 const member = { physicalProjectKey, project: winner };
327 const existing = groupedMembers.get(logicalKey);
328 if (existing) {
329 existing.push(member);
330 } else {
331 groupedMembers.set(logicalKey, [member]);
332 }
333 }
334
335 const projectRefsByLogicalKey = new Map<string, ScopedProjectRef[]>();
336 const seenProjectRefs = new Set<string>();
337 for (const project of input.projects) {
338 const physicalProjectKey = derivePhysicalProjectKey(project);
339 const logicalKey =
340 logicalKeyByPhysicalKey.get(physicalProjectKey) ??
341 deriveLogicalProjectKeyFromSettings(project, input.settings, {
342 groupScratchProjects: input.groupScratchProjects ?? false,
343 });
344 const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
345 if (seenProjectRefs.has(projectRefKey)) continue;
346 seenProjectRefs.add(projectRefKey);
347 const projectRef = scopeProjectRef(project.environmentId, project.id);
348 const existing = projectRefsByLogicalKey.get(logicalKey);
349 if (existing) {
350 existing.push(projectRef);
351 } else {
352 projectRefsByLogicalKey.set(logicalKey, [projectRef]);
353 }
354 }
355
356 const preferredEnvironmentId = input.preferredEnvironmentId ?? null;
357 return Array.from(groupedMembers, ([key, members]) => {
358 const representative =
359 (preferredEnvironmentId
360 ? members.find((member) => member.project.environmentId === preferredEnvironmentId)?.project
361 : null) ?? members[0]!.project;
362 return {
363 key,
364 label:
365 input.groupScratchProjects && representative.isScratch
366 ? "No project"
367 : members.length > 1
368 ? deriveProjectGroupLabel({
369 representative,
370 members: members.map((member) => member.project),
371 })
372 : representative.title,
373 representative,
374 members,
375 memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [],
376 };
377 });
378}
379
380/**
381 * Orders projects the way a sorted group list shows them: by the first group holding each
382 * project. Projects in no group follow, and ties keep their incoming order.
383 */
384export function sortProjectsByGroupOrder<
385 TProject extends { readonly id: string; readonly environmentId: string },
386>(
387 projects: ReadonlyArray<TProject>,
388 groups: ReadonlyArray<{ readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef> }>,
389): TProject[] {
390 const rankByProject = new Map<string, number>();
391 groups.forEach((group, rank) => {
392 for (const ref of group.memberProjectRefs) {
393 const key = `${ref.environmentId}\0${ref.projectId}`;
394 if (!rankByProject.has(key)) rankByProject.set(key, rank);
395 }
396 });
397 const rankOf = (project: TProject) =>
398 rankByProject.get(`${project.environmentId}\0${project.id}`) ?? groups.length;
399 return [...projects].sort((left, right) => rankOf(left) - rankOf(right));
400}