141 lines · 5.4 KB · TypeScript
RawDownload
import { findProjectByPath } from "@t3tools/client-runtime/state/projects";import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts"; const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;/** One or two threads in a directory is usually a one-off question, not a project. */const DEFAULT_SELECTION_MIN_THREADS = 3; /** * Existing projects still need their agent history imported, so every scan * candidate is offered. The default selection is narrower: git repositories * active in the last 30 days with enough threads to look like real work. * Servers that predate the git scan omit `git`; their candidates are treated * as repositories so old computers still get a useful default selection. */export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(  candidates: ReadonlyArray<T>,  now = Date.now(),) {  const cutoff = now - RECENT_PROJECT_WINDOW_MS;   return {    available: candidates,    recent: candidates.filter((candidate) => {      if (candidate.git === null) return false;      if (candidate.threadCount < DEFAULT_SELECTION_MIN_THREADS) return false;      if (candidate.lastActiveAt === null) return false;      const lastActiveAt = Date.parse(candidate.lastActiveAt);      return lastActiveAt >= cutoff && lastActiveAt <= now;    }),  };} export interface OnboardingProjectGroup<T> {  /** Stable identity for collapse state and React keys. */  readonly key: string;  /** GitHub `owner/name`, or the checkout's folder name when the origin is elsewhere. */  readonly label: string;  readonly repository: string | null;  readonly candidates: ReadonlyArray<T>;  readonly threadCount: number;  readonly lastActiveAt: string | null;} function latestActivity(left: string | null, right: string | null): string | null {  if (left === null) return right;  if (right === null) return left;  return left > right ? left : right;} /** * Group scan candidates for the onboarding picker. Clones of one repository * share a group keyed by their normalized origin URL. Repositories without an * origin get a group each, as do candidates from servers that do not report * git identity. Directories that are not git repositories are returned * separately so the UI can fold them away by default. Groups sort by most * recent activity, newest first. */export function groupOnboardingProjects<  T extends Pick<    AgentSessionProjectCandidate,    "path" | "title" | "git" | "threadCount" | "lastActiveAt"  >,>(candidates: ReadonlyArray<T>) {  const groups = new Map<string, OnboardingProjectGroup<T> & { candidates: Array<T> }>();  const other: Array<T> = [];   for (const candidate of candidates) {    if (candidate.git === null) {      other.push(candidate);      continue;    }    const git = candidate.git ?? { remoteKey: null, repository: null };    const key = git.remoteKey === null ? `path:${candidate.path}` : `remote:${git.remoteKey}`;    const existing = groups.get(key);    if (existing === undefined) {      groups.set(key, {        key,        label: git.repository ?? candidate.title,        repository: git.repository,        candidates: [candidate],        threadCount: candidate.threadCount,        lastActiveAt: candidate.lastActiveAt,      });      continue;    }    existing.candidates.push(candidate);    groups.set(key, {      ...existing,      threadCount: existing.threadCount + candidate.threadCount,      lastActiveAt: latestActivity(existing.lastActiveAt, candidate.lastActiveAt),    });  }   const repositories = [...groups.values()].sort((left, right) => {    if (left.lastActiveAt === right.lastActiveAt) return left.label.localeCompare(right.label);    if (left.lastActiveAt === null) return 1;    if (right.lastActiveAt === null) return -1;    return right.lastActiveAt.localeCompare(left.lastActiveAt);  });   return { repositories, other };} /** Use the server's project match before the client snapshot, which can lag behind the scan. */export function resolveOnboardingProjectId(  projects: ReadonlyArray<{    readonly id: ProjectId;    readonly environmentId: EnvironmentId;    readonly workspaceRoot: string;  }>,  environmentId: EnvironmentId,  candidate: Pick<AgentSessionProjectCandidate, "path" | "projectId">,): ProjectId | null {  if (candidate.projectId !== undefined) return candidate.projectId;  const environmentProjects = projects.filter((project) => project.environmentId === environmentId);  const currentRootMatch = findProjectByPath(environmentProjects, candidate.path);  if (currentRootMatch !== undefined) return currentRootMatch.id;  return null;} /** Prefer a selected project with imported history, then a completed empty import. */export function resolveOnboardingLandingProject<T>(  selection: ReadonlyArray<string>,  projectsWithImportedHistory: ReadonlyMap<string, T>,  completedProjects: ReadonlyMap<string, T>,): T | undefined {  for (const path of selection) {    const project = projectsWithImportedHistory.get(path);    if (project !== undefined) return project;  }  for (const path of selection) {    const project = completedProjects.get(path);    if (project !== undefined) return project;  }  return undefined;} /** Paths identify projects only within the computer that owns them. */export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string {  return JSON.stringify([environmentId, path]);}