1import * as Schema from "effect/Schema";
2
3import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import {
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. */
18export const PROJECT_CLONE_DETAIL_MAX_LENGTH = 200;
19export const PROJECT_CLONE_ERROR_MAX_LENGTH = 1000;
20
21/** Follows git's own clone phases as they appear on stderr, in order. */
22export const ProjectCloneStage = Schema.Literals([
23 "connecting",
24 "counting",
25 "receiving",
26 "resolving",
27 "checkout",
28]);
29export type ProjectCloneStage = typeof ProjectCloneStage.Type;
30
31export const ProjectClonePhase = Schema.Literals(["running", "done", "failed", "cancelled"]);
32export type ProjectClonePhase = typeof ProjectClonePhase.Type;
33
34export 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});
51export type ProjectCloneSnapshot = typeof ProjectCloneSnapshot.Type;
52
53export const ProjectCloneSubscribeInput = Schema.Struct({});
54export type ProjectCloneSubscribeInput = typeof ProjectCloneSubscribeInput.Type;
55
56/** Every tracked clone on the environment. Sent first, then after every change. */
57export const ProjectCloneListEvent = Schema.Array(ProjectCloneSnapshot);
58export type ProjectCloneListEvent = typeof ProjectCloneListEvent.Type;
59
60export 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});
70export type ProjectCloneStartInput = typeof ProjectCloneStartInput.Type;
71
72export const ProjectCloneStartResult = Schema.Struct({
73 projectId: ProjectId,
74 cwd: TrimmedNonEmptyString,
75 remoteUrl: TrimmedNonEmptyString,
76 repository: Schema.NullOr(SourceControlRepositoryInfo),
77});
78export type ProjectCloneStartResult = typeof ProjectCloneStartResult.Type;
79
80export const ProjectCloneActionInput = Schema.Struct({
81 projectId: ProjectId,
82});
83export type ProjectCloneActionInput = typeof ProjectCloneActionInput.Type;
84
85export const ProjectCloneActionResult = Schema.Struct({
86 applied: Schema.Boolean,
87});
88export type ProjectCloneActionResult = typeof ProjectCloneActionResult.Type;
89
90function 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. */
106export 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`. */
115export 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}