1import {
2 findProjectByPath,
3 normalizeProjectPathForComparison,
4} from "@t3tools/client-runtime/state/projects";
5import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts";
6
7const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
8const DEFAULT_SELECTION_MIN_THREADS = 3;
9
10/** Treat candidates missing git as repositories for servers predating git scans. */
11export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(
12 candidates: ReadonlyArray<T>,
13 now = Date.now(),
14) {
15 const cutoff = now - RECENT_PROJECT_WINDOW_MS;
16
17 return {
18 available: candidates,
19 recent: candidates.filter((candidate) => {
20 if (candidate.git === null) return false;
21 if (candidate.threadCount < DEFAULT_SELECTION_MIN_THREADS) return false;
22 if (candidate.lastActiveAt === null) return false;
23 const lastActiveAt = Date.parse(candidate.lastActiveAt);
24 return lastActiveAt >= cutoff && lastActiveAt <= now;
25 }),
26 };
27}
28
29export interface OnboardingProjectGroup<T> {
30 readonly key: string;
31 readonly label: string;
32 readonly repository: string | null;
33 readonly candidates: ReadonlyArray<T>;
34 readonly threadCount: number;
35 readonly lastActiveAt: string | null;
36}
37
38function latestActivity(left: string | null, right: string | null): string | null {
39 if (left === null) return right;
40 if (right === null) return left;
41 return left > right ? left : right;
42}
43
44export function groupOnboardingProjects<
45 T extends Pick<
46 AgentSessionProjectCandidate,
47 "path" | "title" | "git" | "threadCount" | "lastActiveAt"
48 >,
49>(candidates: ReadonlyArray<T>) {
50 const groups = new Map<string, OnboardingProjectGroup<T> & { candidates: Array<T> }>();
51 const other: Array<T> = [];
52
53 for (const candidate of candidates) {
54 if (candidate.git === null) {
55 other.push(candidate);
56 continue;
57 }
58 const git = candidate.git ?? { remoteKey: null, repository: null };
59 const key = git.remoteKey === null ? `path:${candidate.path}` : `remote:${git.remoteKey}`;
60 const existing = groups.get(key);
61 if (existing === undefined) {
62 groups.set(key, {
63 key,
64 label: git.repository ?? candidate.title,
65 repository: git.repository,
66 candidates: [candidate],
67 threadCount: candidate.threadCount,
68 lastActiveAt: candidate.lastActiveAt,
69 });
70 continue;
71 }
72 existing.candidates.push(candidate);
73 groups.set(key, {
74 ...existing,
75 threadCount: existing.threadCount + candidate.threadCount,
76 lastActiveAt: latestActivity(existing.lastActiveAt, candidate.lastActiveAt),
77 });
78 }
79
80 const repositories = [...groups.values()].sort((left, right) => {
81 if (left.lastActiveAt === right.lastActiveAt) return left.label.localeCompare(right.label);
82 if (left.lastActiveAt === null) return 1;
83 if (right.lastActiveAt === null) return -1;
84 return right.lastActiveAt.localeCompare(left.lastActiveAt);
85 });
86
87 return { repositories, other };
88}
89
90/** Use the server's project match before the client snapshot, which can lag behind the scan. */
91export function resolveOnboardingProjectId(
92 projects: ReadonlyArray<{
93 readonly id: ProjectId;
94 readonly environmentId: EnvironmentId;
95 readonly workspaceRoot: string;
96 }>,
97 environmentId: EnvironmentId,
98 candidate: Pick<AgentSessionProjectCandidate, "path" | "projectId">,
99): ProjectId | null {
100 if (candidate.projectId !== undefined) return candidate.projectId;
101 const environmentProjects = projects.filter((project) => project.environmentId === environmentId);
102 const currentRootMatch = findProjectByPath(environmentProjects, candidate.path);
103 if (currentRootMatch !== undefined) return currentRootMatch.id;
104 return null;
105}
106
107/**
108 * Whether a candidate is not a project yet, by the same rules as `resolveOnboardingProjectId`.
109 * It reads the project list once, so a long candidate list costs one pass, not one per candidate.
110 */
111export function isNewOnboardingProject(
112 projects: ReadonlyArray<{
113 readonly environmentId: EnvironmentId;
114 readonly workspaceRoot: string;
115 }>,
116) {
117 const roots = new Set(
118 projects.map(
119 (project) =>
120 `${project.environmentId}\u0000${normalizeProjectPathForComparison(project.workspaceRoot)}`,
121 ),
122 );
123 return (
124 environmentId: EnvironmentId,
125 candidate: Pick<AgentSessionProjectCandidate, "path" | "projectId">,
126 ) => {
127 if (candidate.projectId !== undefined) return false;
128 const path = normalizeProjectPathForComparison(candidate.path);
129 return path.length === 0 || !roots.has(`${environmentId}\u0000${path}`);
130 };
131}
132
133export function resolveOnboardingLandingProject<T>(
134 selection: ReadonlyArray<string>,
135 projectsWithImportedHistory: ReadonlyMap<string, T>,
136 completedProjects: ReadonlyMap<string, T>,
137): T | undefined {
138 for (const path of selection) {
139 const project = projectsWithImportedHistory.get(path);
140 if (project !== undefined) return project;
141 }
142 for (const path of selection) {
143 const project = completedProjects.get(path);
144 if (project !== undefined) return project;
145 }
146 return undefined;
147}
148
149export function splitOnboardingProjectPath(path: string): {
150 readonly name: string;
151 readonly parent: string;
152} {
153 if (/^[A-Za-z]:[\\/]+$/.test(path) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]*$/.test(path)) {
154 return { name: path, parent: "" };
155 }
156 const trimmed = path.replace(/[\\/]+$/, "");
157 const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
158 if (trimmed.length === 0) return { name: path, parent: "" };
159 if (separatorIndex < 0) return { name: trimmed, parent: "" };
160 return { name: trimmed.slice(separatorIndex + 1), parent: trimmed.slice(0, separatorIndex + 1) };
161}
162
163export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string {
164 return JSON.stringify([environmentId, path]);
165}