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<string, SidebarProjectGroupingMode>;} export type ProjectGroupingMode = SidebarProjectGroupingMode; export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings { return { sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, };} function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): string[] { const seen = new Set<string>(); 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<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,): 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<EnvironmentProject, "environmentId" | "workspaceRoot">,): string { return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot);} export function deriveProjectGroupingOverrideKey( project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,): string { return derivePhysicalProjectKey(project);} export function getProjectOrderKey( project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,): string { return derivePhysicalProjectKey(project);} export function resolveProjectGroupingMode( project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, settings: ProjectGroupingSettings,): SidebarProjectGroupingMode { return ( settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? settings.sidebarProjectGroupingMode );} function deriveRepositoryScopedKey( project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, 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<EnvironmentProject, "title" | "repositoryIdentity">; readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;}): 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<TProject extends EnvironmentProject = EnvironmentProject> { readonly physicalProjectKey: string; readonly project: TProject;} export interface ProjectGroup<TProject extends EnvironmentProject = EnvironmentProject> { readonly key: string; readonly label: string; readonly representative: TProject; readonly members: ReadonlyArray<ProjectGroupMember<TProject>>; readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef>;} 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<TProject extends EnvironmentProject>( existing: TProject, candidate: TProject,): boolean { const freshnessDelta = projectFreshnessTime(candidate) - projectFreshnessTime(existing); return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > existing.id);} function selectProjectIdentitySource<TProject extends EnvironmentProject>( projects: ReadonlyArray<TProject>, 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<TProject extends EnvironmentProject>(input: { readonly projects: ReadonlyArray<TProject>; readonly settings: ProjectGroupingSettings; readonly preferredEnvironmentId?: EnvironmentId | null;}): ReadonlyArray<ProjectGroup<TProject>> { const projectsByPhysicalKey = new Map<string, TProject[]>(); 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<string, string>(); const groupedMembers = new Map<string, ProjectGroupMember<TProject>[]>(); 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<string, ScopedProjectRef[]>(); const seenProjectRefs = new Set<string>(); 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) ?? [], }; });}
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<string, SidebarProjectGroupingMode>;
}
export type ProjectGroupingMode = SidebarProjectGroupingMode;
export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings {
return {
sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
};
}
function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): string[] {
const seen = new Set<string>();
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<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
): 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<EnvironmentProject, "environmentId" | "workspaceRoot">,
): string {
return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot);
}
export function deriveProjectGroupingOverrideKey(
project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
): string {
return derivePhysicalProjectKey(project);
}
export function getProjectOrderKey(
project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
): string {
return derivePhysicalProjectKey(project);
}
export function resolveProjectGroupingMode(
project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
settings: ProjectGroupingSettings,
): SidebarProjectGroupingMode {
return (
settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ??
settings.sidebarProjectGroupingMode
);
}
function deriveRepositoryScopedKey(
project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
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<EnvironmentProject, "title" | "repositoryIdentity">;
readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;
}): 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<TProject extends EnvironmentProject = EnvironmentProject> {
readonly physicalProjectKey: string;
readonly project: TProject;
}
export interface ProjectGroup<TProject extends EnvironmentProject = EnvironmentProject> {
readonly key: string;
readonly label: string;
readonly representative: TProject;
readonly members: ReadonlyArray<ProjectGroupMember<TProject>>;
readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef>;
}
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<TProject extends EnvironmentProject>(
existing: TProject,
candidate: TProject,
): boolean {
const freshnessDelta = projectFreshnessTime(candidate) - projectFreshnessTime(existing);
return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > existing.id);
}
function selectProjectIdentitySource<TProject extends EnvironmentProject>(
projects: ReadonlyArray<TProject>,
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<TProject extends EnvironmentProject>(input: {
readonly projects: ReadonlyArray<TProject>;
readonly settings: ProjectGroupingSettings;
readonly preferredEnvironmentId?: EnvironmentId | null;
}): ReadonlyArray<ProjectGroup<TProject>> {
const projectsByPhysicalKey = new Map<string, TProject[]>();
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<string, string>();
const groupedMembers = new Map<string, ProjectGroupMember<TProject>[]>();
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<string, ScopedProjectRef[]>();
const seenProjectRefs = new Set<string>();
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) ?? [],
};
});
}