| 1 | import * as Schema from "effect/Schema"; |
| 2 | |
| 3 | import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | import { |
| 5 | SourceControlCloneProtocol, |
| 6 | SourceControlProviderKind, |
| 7 | SourceControlRepositoryInfo, |
| 8 | } from "./sourceControl.ts"; |
| 9 | |
| 10 | /** |
| 11 | * Live progress of a repository clone that backs a freshly added project. The |
| 12 | * server keeps this in memory only: a finished clone is dropped after a short |
| 13 | * grace period, a failed one stays until it is retried or the project is |
| 14 | * removed, and a server restart forgets in-flight clones (the project keeps |
| 15 | * its empty workspace root and can be removed like any other project). |
| 16 | */ |
| 17 | /** Producers clamp free text to these before publishing so encoding never fails. */ |
| 18 | export const PROJECT_CLONE_DETAIL_MAX_LENGTH = 200; |
| 19 | export const PROJECT_CLONE_ERROR_MAX_LENGTH = 1000; |
| 20 | |
| 21 | /** Follows git's own clone phases as they appear on stderr, in order. */ |
| 22 | export const ProjectCloneStage = Schema.Literals([ |
| 23 | "connecting", |
| 24 | "counting", |
| 25 | "receiving", |
| 26 | "resolving", |
| 27 | "checkout", |
| 28 | ]); |
| 29 | export type ProjectCloneStage = typeof ProjectCloneStage.Type; |
| 30 | |
| 31 | export const ProjectClonePhase = Schema.Literals(["running", "done", "failed", "cancelled"]); |
| 32 | export type ProjectClonePhase = typeof ProjectClonePhase.Type; |
| 33 | |
| 34 | export const ProjectCloneSnapshot = Schema.Struct({ |
| 35 | projectId: ProjectId, |
| 36 | remoteUrl: TrimmedNonEmptyString, |
| 37 | destinationPath: TrimmedNonEmptyString, |
| 38 | repository: Schema.NullOr(SourceControlRepositoryInfo), |
| 39 | phase: ProjectClonePhase, |
| 40 | stage: ProjectCloneStage, |
| 41 | /** Percent of the current stage, parsed from git's progress lines. */ |
| 42 | percent: Schema.NullOr(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))), |
| 43 | /** Trailing text from the progress line, typically transfer size and rate. */ |
| 44 | detail: Schema.NullOr(Schema.String.check(Schema.isMaxLength(PROJECT_CLONE_DETAIL_MAX_LENGTH))), |
| 45 | /** Human readable reason when phase is failed. */ |
| 46 | error: Schema.NullOr(Schema.String.check(Schema.isMaxLength(PROJECT_CLONE_ERROR_MAX_LENGTH))), |
| 47 | startedAt: IsoDateTime, |
| 48 | endedAt: Schema.NullOr(IsoDateTime), |
| 49 | sequence: NonNegativeInt, |
| 50 | }); |
| 51 | export type ProjectCloneSnapshot = typeof ProjectCloneSnapshot.Type; |
| 52 | |
| 53 | export const ProjectCloneSubscribeInput = Schema.Struct({}); |
| 54 | export type ProjectCloneSubscribeInput = typeof ProjectCloneSubscribeInput.Type; |
| 55 | |
| 56 | /** Every tracked clone on the environment. Sent first, then after every change. */ |
| 57 | export const ProjectCloneListEvent = Schema.Array(ProjectCloneSnapshot); |
| 58 | export type ProjectCloneListEvent = typeof ProjectCloneListEvent.Type; |
| 59 | |
| 60 | export const ProjectCloneStartInput = Schema.Struct({ |
| 61 | projectId: ProjectId, |
| 62 | title: TrimmedNonEmptyString, |
| 63 | createdAt: IsoDateTime, |
| 64 | provider: Schema.optional(SourceControlProviderKind), |
| 65 | repository: Schema.optional(TrimmedNonEmptyString), |
| 66 | remoteUrl: Schema.optional(TrimmedNonEmptyString), |
| 67 | destinationPath: TrimmedNonEmptyString, |
| 68 | protocol: Schema.optional(SourceControlCloneProtocol), |
| 69 | }); |
| 70 | export type ProjectCloneStartInput = typeof ProjectCloneStartInput.Type; |
| 71 | |
| 72 | export const ProjectCloneStartResult = Schema.Struct({ |
| 73 | projectId: ProjectId, |
| 74 | cwd: TrimmedNonEmptyString, |
| 75 | remoteUrl: TrimmedNonEmptyString, |
| 76 | repository: Schema.NullOr(SourceControlRepositoryInfo), |
| 77 | }); |
| 78 | export type ProjectCloneStartResult = typeof ProjectCloneStartResult.Type; |
| 79 | |
| 80 | export const ProjectCloneActionInput = Schema.Struct({ |
| 81 | projectId: ProjectId, |
| 82 | }); |
| 83 | export type ProjectCloneActionInput = typeof ProjectCloneActionInput.Type; |
| 84 | |
| 85 | export const ProjectCloneActionResult = Schema.Struct({ |
| 86 | applied: Schema.Boolean, |
| 87 | }); |
| 88 | export type ProjectCloneActionResult = typeof ProjectCloneActionResult.Type; |
| 89 | |
| 90 | function projectCloneStageLabel(stage: ProjectCloneStage): string { |
| 91 | switch (stage) { |
| 92 | case "connecting": |
| 93 | return "Connecting"; |
| 94 | case "counting": |
| 95 | return "Counting objects"; |
| 96 | case "receiving": |
| 97 | return "Receiving objects"; |
| 98 | case "resolving": |
| 99 | return "Resolving deltas"; |
| 100 | case "checkout": |
| 101 | return "Checking out files"; |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /** Display name for a clone: the looked-up `owner/repo`, else the folder being cloned into. */ |
| 106 | export function projectCloneDisplayName( |
| 107 | snapshot: Pick<ProjectCloneSnapshot, "repository" | "destinationPath">, |
| 108 | ): string { |
| 109 | if (snapshot.repository) return snapshot.repository.nameWithOwner; |
| 110 | const segments = snapshot.destinationPath.split(/[/\\]/).filter((segment) => segment.length > 0); |
| 111 | return segments[segments.length - 1] ?? snapshot.destinationPath; |
| 112 | } |
| 113 | |
| 114 | /** One-line progress summary: `Receiving objects · 45% · 12.3 MiB | 5.0 MiB/s`. */ |
| 115 | export function projectCloneProgressSummary( |
| 116 | snapshot: Pick<ProjectCloneSnapshot, "stage" | "percent" | "detail">, |
| 117 | ): string { |
| 118 | const parts = [projectCloneStageLabel(snapshot.stage)]; |
| 119 | if (snapshot.percent !== null) parts.push(`${snapshot.percent}%`); |
| 120 | if (snapshot.detail) parts.push(snapshot.detail); |
| 121 | return parts.join(" · "); |
| 122 | } |