| 1 | import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; |
| 2 | import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 3 | |
| 4 | const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; |
| 5 | /** One or two threads in a directory is usually a one-off question, not a project. */ |
| 6 | const DEFAULT_SELECTION_MIN_THREADS = 3; |
| 7 | |
| 8 | /** |
| 9 | * Existing projects still need their agent history imported, so every scan |
| 10 | * candidate is offered. The default selection is narrower: git repositories |
| 11 | * active in the last 30 days with enough threads to look like real work. |
| 12 | * Servers that predate the git scan omit `git`; their candidates are treated |
| 13 | * as repositories so old computers still get a useful default selection. |
| 14 | */ |
| 15 | export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>( |
| 16 | candidates: ReadonlyArray<T>, |
| 17 | now = Date.now(), |
| 18 | ) { |
| 19 | const cutoff = now - RECENT_PROJECT_WINDOW_MS; |
| 20 | |
| 21 | return { |
| 22 | available: candidates, |
| 23 | recent: candidates.filter((candidate) => { |
| 24 | if (candidate.git === null) return false; |
| 25 | if (candidate.threadCount < DEFAULT_SELECTION_MIN_THREADS) return false; |
| 26 | if (candidate.lastActiveAt === null) return false; |
| 27 | const lastActiveAt = Date.parse(candidate.lastActiveAt); |
| 28 | return lastActiveAt >= cutoff && lastActiveAt <= now; |
| 29 | }), |
| 30 | }; |
| 31 | } |
| 32 | |
| 33 | export interface OnboardingProjectGroup<T> { |
| 34 | /** Stable identity for collapse state and React keys. */ |
| 35 | readonly key: string; |
| 36 | /** GitHub `owner/name`, or the checkout's folder name when the origin is elsewhere. */ |
| 37 | readonly label: string; |
| 38 | readonly repository: string | null; |
| 39 | readonly candidates: ReadonlyArray<T>; |
| 40 | readonly threadCount: number; |
| 41 | readonly lastActiveAt: string | null; |
| 42 | } |
| 43 | |
| 44 | function latestActivity(left: string | null, right: string | null): string | null { |
| 45 | if (left === null) return right; |
| 46 | if (right === null) return left; |
| 47 | return left > right ? left : right; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Group scan candidates for the onboarding picker. Clones of one repository |
| 52 | * share a group keyed by their normalized origin URL. Repositories without an |
| 53 | * origin get a group each, as do candidates from servers that do not report |
| 54 | * git identity. Directories that are not git repositories are returned |
| 55 | * separately so the UI can fold them away by default. Groups sort by most |
| 56 | * recent activity, newest first. |
| 57 | */ |
| 58 | export function groupOnboardingProjects< |
| 59 | T extends Pick< |
| 60 | AgentSessionProjectCandidate, |
| 61 | "path" | "title" | "git" | "threadCount" | "lastActiveAt" |
| 62 | >, |
| 63 | >(candidates: ReadonlyArray<T>) { |
| 64 | const groups = new Map<string, OnboardingProjectGroup<T> & { candidates: Array<T> }>(); |
| 65 | const other: Array<T> = []; |
| 66 | |
| 67 | for (const candidate of candidates) { |
| 68 | if (candidate.git === null) { |
| 69 | other.push(candidate); |
| 70 | continue; |
| 71 | } |
| 72 | const git = candidate.git ?? { remoteKey: null, repository: null }; |
| 73 | const key = git.remoteKey === null ? `path:${candidate.path}` : `remote:${git.remoteKey}`; |
| 74 | const existing = groups.get(key); |
| 75 | if (existing === undefined) { |
| 76 | groups.set(key, { |
| 77 | key, |
| 78 | label: git.repository ?? candidate.title, |
| 79 | repository: git.repository, |
| 80 | candidates: [candidate], |
| 81 | threadCount: candidate.threadCount, |
| 82 | lastActiveAt: candidate.lastActiveAt, |
| 83 | }); |
| 84 | continue; |
| 85 | } |
| 86 | existing.candidates.push(candidate); |
| 87 | groups.set(key, { |
| 88 | ...existing, |
| 89 | threadCount: existing.threadCount + candidate.threadCount, |
| 90 | lastActiveAt: latestActivity(existing.lastActiveAt, candidate.lastActiveAt), |
| 91 | }); |
| 92 | } |
| 93 | |
| 94 | const repositories = [...groups.values()].sort((left, right) => { |
| 95 | if (left.lastActiveAt === right.lastActiveAt) return left.label.localeCompare(right.label); |
| 96 | if (left.lastActiveAt === null) return 1; |
| 97 | if (right.lastActiveAt === null) return -1; |
| 98 | return right.lastActiveAt.localeCompare(left.lastActiveAt); |
| 99 | }); |
| 100 | |
| 101 | return { repositories, other }; |
| 102 | } |
| 103 | |
| 104 | /** Use the server's project match before the client snapshot, which can lag behind the scan. */ |
| 105 | export function resolveOnboardingProjectId( |
| 106 | projects: ReadonlyArray<{ |
| 107 | readonly id: ProjectId; |
| 108 | readonly environmentId: EnvironmentId; |
| 109 | readonly workspaceRoot: string; |
| 110 | }>, |
| 111 | environmentId: EnvironmentId, |
| 112 | candidate: Pick<AgentSessionProjectCandidate, "path" | "projectId">, |
| 113 | ): ProjectId | null { |
| 114 | if (candidate.projectId !== undefined) return candidate.projectId; |
| 115 | const environmentProjects = projects.filter((project) => project.environmentId === environmentId); |
| 116 | const currentRootMatch = findProjectByPath(environmentProjects, candidate.path); |
| 117 | if (currentRootMatch !== undefined) return currentRootMatch.id; |
| 118 | return null; |
| 119 | } |
| 120 | |
| 121 | /** Prefer a selected project with imported history, then a completed empty import. */ |
| 122 | export function resolveOnboardingLandingProject<T>( |
| 123 | selection: ReadonlyArray<string>, |
| 124 | projectsWithImportedHistory: ReadonlyMap<string, T>, |
| 125 | completedProjects: ReadonlyMap<string, T>, |
| 126 | ): T | undefined { |
| 127 | for (const path of selection) { |
| 128 | const project = projectsWithImportedHistory.get(path); |
| 129 | if (project !== undefined) return project; |
| 130 | } |
| 131 | for (const path of selection) { |
| 132 | const project = completedProjects.get(path); |
| 133 | if (project !== undefined) return project; |
| 134 | } |
| 135 | return undefined; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * Splits a scan path into the folder name and the directory above it (with |
| 140 | * its trailing separator), so a picker row can keep the name visible and let |
| 141 | * the shared prefix truncate. Handles both separators because candidates |
| 142 | * come from any environment. |
| 143 | */ |
| 144 | export function splitOnboardingProjectPath(path: string): { |
| 145 | readonly name: string; |
| 146 | readonly parent: string; |
| 147 | } { |
| 148 | if (/^[A-Za-z]:[\\/]+$/.test(path) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]*$/.test(path)) { |
| 149 | return { name: path, parent: "" }; |
| 150 | } |
| 151 | const trimmed = path.replace(/[\\/]+$/, ""); |
| 152 | const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); |
| 153 | if (trimmed.length === 0) return { name: path, parent: "" }; |
| 154 | if (separatorIndex < 0) return { name: trimmed, parent: "" }; |
| 155 | return { name: trimmed.slice(separatorIndex + 1), parent: trimmed.slice(0, separatorIndex + 1) }; |
| 156 | } |
| 157 | |
| 158 | /** Paths identify projects only within the computer that owns them. */ |
| 159 | export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string { |
| 160 | return JSON.stringify([environmentId, path]); |
| 161 | } |