import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; import { sourceControlClients } from "../sourceControlClients.ts"; import { repositoryGroupingDisplayNameOf, repositoryGroupingKeyOf, type EnvironmentId, type RepositoryIdentity, type ScopedProjectRef, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import type { EnvironmentProject } from "./models.ts"; import { normalizeProjectPathForComparison } from "./projects.ts"; const SCRATCH_PROJECT_SCOPE_KEY = "\0no-project"; 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; } /** Labels of one group share a lower-case grouping key, so a case-only difference is one label. */ function uniqueLabels(values: ReadonlyArray): string[] { const byKey = new Map(); for (const value of values) { const key = value.toLowerCase(); const kept = byKey.get(key); if (kept === undefined || (kept === key && value !== key)) byKey.set(key, value); } return [...byKey.values()]; } 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); } function deriveProjectGroupingOverrideKey( project: Pick, ): string { return derivePhysicalProjectKey(project); } export function getProjectOrderKey( project: Pick, ): string { return derivePhysicalProjectKey(project); } 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 ? repositoryGroupingKeyOf(project.repositoryIdentity) : null; // A NUL reserves the scratch key namespace outside valid repository identities. if (!canonicalKey || canonicalKey.includes("\0")) { return null; } if (groupingMode === "repository") { return canonicalKey; } const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); if (relativeProjectPath === null) { return canonicalKey; } return relativeProjectPath.length === 0 ? canonicalKey : `${canonicalKey}::${relativeProjectPath}`; } function deriveLogicalProjectKey( project: Pick< EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" >, options?: { readonly groupingMode?: SidebarProjectGroupingMode; readonly groupScratchProjects?: boolean; }, ): string { if (options?.groupScratchProjects && project.isScratch) return SCRATCH_PROJECT_SCOPE_KEY; 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" | "isScratch" >, settings: ProjectGroupingSettings, options?: { readonly groupScratchProjects?: boolean }, ): string { return deriveLogicalProjectKey(project, { groupingMode: resolveProjectGroupingMode(project, settings), groupScratchProjects: options?.groupScratchProjects ?? false, }); } export function resolveScratchProjectScopeKey( key: string | null, projects: ReadonlyArray< Pick< EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" > >, settings: ProjectGroupingSettings = { sidebarProjectGroupingMode: "repository", sidebarProjectGroupingOverrides: {}, }, ): string | null { if (key === null || key === SCRATCH_PROJECT_SCOPE_KEY) return key; if ( projects.some( (project) => deriveLogicalProjectKeyFromSettings(project, settings, { groupScratchProjects: true }) === key, ) ) { return key; } return projects.some((project) => { if (!project.isScratch) return false; const mode = resolveProjectGroupingMode(project, settings); return ( derivePhysicalProjectKey(project) === key || (mode !== "separate" && deriveRepositoryScopedKey(project, mode) === key) ); }) ? SCRATCH_PROJECT_SCOPE_KEY : key; } /** Servers too old to send an origin's URL still send its key, whose first segment is the host. */ function repositoryGroupingRemoteOf(identity: RepositoryIdentity): string { const { origin } = identity; if (!origin) return identity.locator.remoteUrl; if (origin.remoteUrl) return origin.remoteUrl; const [host] = origin.canonicalKey.split("/"); return `https://${origin.displayName ? `${host}/${origin.displayName}` : origin.canonicalKey}`; } function repositoryGroupingLabelOf(identity: RepositoryIdentity): string | undefined { return ( sourceControlClients.repositoryLabel(repositoryGroupingRemoteOf(identity)) ?? repositoryGroupingDisplayNameOf(identity) ); } export function deriveProjectGroupLabel(input: { readonly representative: Pick; readonly members: ReadonlyArray>; }): string { const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title)); const displayNames = uniqueNonEmptyValues( input.members.map((member) => member.repositoryIdentity ? repositoryGroupingLabelOf(member.repositoryIdentity) : null, ), ); const sharedDisplayNames = uniqueLabels(displayNames); const sharedRepositoryNames = uniqueNonEmptyValues( input.members.map((member) => member.repositoryIdentity?.name), ); const sharedTitle = sharedTitles[0]; // Servers lower-case names they cannot match to the remote, so a folder title can differ in case. const titleKey = sharedTitle?.toLowerCase(); const namesTitle = (name: string) => name.toLowerCase() === titleKey; if ( sharedTitles.length === 1 && sharedTitle !== undefined && !displayNames.some(namesTitle) && !sharedRepositoryNames.some(namesTitle) ) { 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; /** Clients write every member together. When members still disagree, the project stays visible. */ readonly pinned: boolean; readonly archived: boolean; } /** The groups a project picker offers: archived groups left out, pinned ones first. */ export function pickableProjectGroups< TGroup extends { readonly pinned: boolean; readonly archived: boolean }, >(groups: ReadonlyArray): TGroup[] { const open = groups.filter((group) => !group.archived); return [...open.filter((group) => group.pinned), ...open.filter((group) => !group.pinned)]; } 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; } export function buildProjectGroups(input: { readonly projects: ReadonlyArray; readonly settings: ProjectGroupingSettings; readonly preferredEnvironmentId?: EnvironmentId | null; readonly groupScratchProjects?: boolean; }): 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), groupScratchProjects: input.groupScratchProjects ?? false, }); 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, { groupScratchProjects: input.groupScratchProjects ?? false, }); 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: input.groupScratchProjects && representative.isScratch ? "No project" : members.length > 1 ? deriveProjectGroupLabel({ representative, members: members.map((member) => member.project), }) : representative.title, representative, members, memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [], pinned: members.some((member) => member.project.pinnedAt != null), archived: members.every((member) => member.project.archivedAt != null), }; }); } export function sortProjectsByGroupOrder< TProject extends { readonly id: string; readonly environmentId: string }, >( projects: ReadonlyArray, groups: ReadonlyArray<{ readonly memberProjectRefs: ReadonlyArray }>, ): TProject[] { const rankByProject = new Map(); groups.forEach((group, rank) => { for (const ref of group.memberProjectRefs) { const key = `${ref.environmentId}\0${ref.projectId}`; if (!rankByProject.has(key)) rankByProject.set(key, rank); } }); const rankOf = (project: TProject) => rankByProject.get(`${project.environmentId}\0${project.id}`) ?? groups.length; return [...projects].sort((left, right) => rankOf(left) - rankOf(right)); }