import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; import type { EnvironmentId, ScopedProjectRef, SidebarProjectGroupingMode, } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import type { EnvironmentProject } from "./models.ts"; import { normalizeProjectPathForComparison } from "./projects.ts"; export interface ProjectGroupingSettings { readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode; readonly sidebarProjectGroupingOverrides: Record; } export type ProjectGroupingMode = SidebarProjectGroupingMode; export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings { return { sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, }; } function uniqueNonEmptyValues(values: ReadonlyArray): string[] { const seen = new Set(); const unique: string[] = []; for (const value of values) { const trimmed = value?.trim(); if (!trimmed || seen.has(trimmed)) { continue; } seen.add(trimmed); unique.push(trimmed); } return unique; } function deriveRepositoryRelativeProjectPath( project: Pick, ): string | null { const rootPath = project.repositoryIdentity?.rootPath?.trim(); if (!rootPath) { return null; } const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); const normalizedRootPath = normalizeProjectPathForComparison(rootPath); if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { return null; } if (normalizedProjectPath === normalizedRootPath) { return ""; } const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; const rootPrefix = `${normalizedRootPath}${separator}`; if (!normalizedProjectPath.startsWith(rootPrefix)) { return null; } return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); } export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; } export function derivePhysicalProjectKey( project: Pick, ): string { return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); } export function deriveProjectGroupingOverrideKey( project: Pick, ): string { return derivePhysicalProjectKey(project); } export function getProjectOrderKey( project: Pick, ): string { return derivePhysicalProjectKey(project); } export function resolveProjectGroupingMode( project: Pick, settings: ProjectGroupingSettings, ): SidebarProjectGroupingMode { return ( settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? settings.sidebarProjectGroupingMode ); } function deriveRepositoryScopedKey( project: Pick, groupingMode: SidebarProjectGroupingMode, ): string | null { const canonicalKey = project.repositoryIdentity?.canonicalKey; if (!canonicalKey) { return null; } if (groupingMode === "repository") { return canonicalKey; } const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); if (relativeProjectPath === null) { return canonicalKey; } return relativeProjectPath.length === 0 ? canonicalKey : `${canonicalKey}::${relativeProjectPath}`; } export function deriveLogicalProjectKey( project: Pick< EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" >, options?: { readonly groupingMode?: SidebarProjectGroupingMode; }, ): string { const groupingMode = options?.groupingMode ?? "repository"; if (groupingMode === "separate") { return derivePhysicalProjectKey(project); } return ( deriveRepositoryScopedKey(project, groupingMode) ?? derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); } export function deriveLogicalProjectKeyFromSettings( project: Pick< EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" >, settings: ProjectGroupingSettings, ): string { return deriveLogicalProjectKey(project, { groupingMode: resolveProjectGroupingMode(project, settings), }); } export function deriveProjectGroupLabel(input: { readonly representative: Pick; readonly members: ReadonlyArray>; }): string { const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title)); const sharedDisplayNames = uniqueNonEmptyValues( input.members.map((member) => member.repositoryIdentity?.displayName), ); const sharedRepositoryNames = uniqueNonEmptyValues( input.members.map((member) => member.repositoryIdentity?.name), ); const sharedTitle = sharedTitles[0]; if ( sharedTitles.length === 1 && sharedTitle !== undefined && !sharedDisplayNames.includes(sharedTitle) && !sharedRepositoryNames.includes(sharedTitle) ) { return sharedTitle; } if (sharedDisplayNames.length === 1) { return sharedDisplayNames[0]!; } if (sharedRepositoryNames.length === 1) { return sharedRepositoryNames[0]!; } return input.representative.title; } export interface ProjectGroupMember { readonly physicalProjectKey: string; readonly project: TProject; } export interface ProjectGroup { readonly key: string; readonly label: string; readonly representative: TProject; readonly members: ReadonlyArray>; readonly memberProjectRefs: ReadonlyArray; } function projectFreshnessTime(project: EnvironmentProject): number { const updatedAtTime = Date.parse(project.updatedAt); if (Number.isFinite(updatedAtTime)) { return updatedAtTime; } const createdAtTime = Date.parse(project.createdAt); return Number.isFinite(createdAtTime) ? createdAtTime : 0; } function shouldReplacePhysicalProjectWinner( existing: TProject, candidate: TProject, ): boolean { const freshnessDelta = projectFreshnessTime(candidate) - projectFreshnessTime(existing); return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > existing.id); } function selectProjectIdentitySource( projects: ReadonlyArray, winner: TProject, ): TProject { if (winner.repositoryIdentity !== null) { return winner; } let freshestIdentifiedProject: TProject | null = null; for (const project of projects) { if (project.repositoryIdentity === null) { continue; } if ( freshestIdentifiedProject === null || shouldReplacePhysicalProjectWinner(freshestIdentifiedProject, project) ) { freshestIdentifiedProject = project; } } return freshestIdentifiedProject ?? winner; } /** * Builds logical project groups without losing the physical projects that * remain the actual navigation and task-creation targets. * * Presentation-specific metadata, filtering, and activity sorting stay in * each client. Grouping modes, overrides, physical deduplication, labels, and * member preservation live here so web and mobile cannot drift. */ export function buildProjectGroups(input: { readonly projects: ReadonlyArray; readonly settings: ProjectGroupingSettings; readonly preferredEnvironmentId?: EnvironmentId | null; }): ReadonlyArray> { const projectsByPhysicalKey = new Map(); for (const project of input.projects) { const physicalProjectKey = derivePhysicalProjectKey(project); const existing = projectsByPhysicalKey.get(physicalProjectKey); if (existing) { existing.push(project); } else { projectsByPhysicalKey.set(physicalProjectKey, [project]); } } const logicalKeyByPhysicalKey = new Map(); const groupedMembers = new Map[]>(); for (const [physicalProjectKey, physicalProjects] of projectsByPhysicalKey) { const winner = physicalProjects.reduce((current, candidate) => shouldReplacePhysicalProjectWinner(current, candidate) ? candidate : current, ); const identitySource = selectProjectIdentitySource(physicalProjects, winner); const logicalKey = deriveLogicalProjectKey(identitySource, { groupingMode: resolveProjectGroupingMode(winner, input.settings), }); logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey); const member = { physicalProjectKey, project: winner }; const existing = groupedMembers.get(logicalKey); if (existing) { existing.push(member); } else { groupedMembers.set(logicalKey, [member]); } } const projectRefsByLogicalKey = new Map(); const seenProjectRefs = new Set(); for (const project of input.projects) { const physicalProjectKey = derivePhysicalProjectKey(project); const logicalKey = logicalKeyByPhysicalKey.get(physicalProjectKey) ?? deriveLogicalProjectKeyFromSettings(project, input.settings); const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); if (seenProjectRefs.has(projectRefKey)) continue; seenProjectRefs.add(projectRefKey); const projectRef = scopeProjectRef(project.environmentId, project.id); const existing = projectRefsByLogicalKey.get(logicalKey); if (existing) { existing.push(projectRef); } else { projectRefsByLogicalKey.set(logicalKey, [projectRef]); } } const preferredEnvironmentId = input.preferredEnvironmentId ?? null; return Array.from(groupedMembers, ([key, members]) => { const representative = (preferredEnvironmentId ? members.find((member) => member.project.environmentId === preferredEnvironmentId)?.project : null) ?? members[0]!.project; return { key, label: members.length > 1 ? deriveProjectGroupLabel({ representative, members: members.map((member) => member.project), }) : representative.title, representative, members, memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [], }; }); }