Skip to content
Source
Sign inSign up

new-task-branch-checkout.ts

126 lines4.8 KB

1import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
2import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
3import { EnvironmentId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Schema from "effect/Schema";
6import { Atom } from "effect/reactivity";
7​
8export type BranchCheckoutResult = AtomCommandResult<VcsRef, unknown>;
9type BranchCheckoutFailure = Extract<BranchCheckoutResult, { readonly _tag: "Failure" }>;
10​
11export interface BranchCheckoutRequest {
12 /** The draft route's flow key; the checkout is abandoned once it leaves the stack. */
13 readonly flowKey: string;
14 readonly routeKey: string;
15 readonly environmentId: EnvironmentId | null;
16 readonly workspaceRoot: string | null;
17 readonly branch: string;
18 readonly worktreePath: string | null;
20​
21// The family key lists the request's fields in a fixed order, so equal
22// requests share one key and start can rebuild the request from it.
23const RequestKey = Schema.fromJsonString(
24 Schema.Tuple([
25 Schema.String,
26 Schema.String,
27 Schema.NullOr(EnvironmentId),
28 Schema.NullOr(Schema.String),
29 Schema.String,
30 Schema.NullOr(Schema.String),
31 ]),
32);
33const encodeRequestKey = Schema.encodeSync(RequestKey);
34const decodeRequestKey = Schema.decodeSync(RequestKey);
35​
36/** A pure, stable key for a request: the same fields always give the same key. */
37export function branchCheckoutRequestKey(request: BranchCheckoutRequest): string {
38 return encodeRequestKey([
39 request.flowKey,
40 request.routeKey,
41 request.environmentId,
42 request.workspaceRoot,
43 request.branch,
44 request.worktreePath,
45 ]);
47​
48function requestFromKey(key: string): BranchCheckoutRequest {
49 const [flowKey, routeKey, environmentId, workspaceRoot, branch, worktreePath] =
50 decodeRequestKey(key);
51 return { flowKey, routeKey, environmentId, workspaceRoot, branch, worktreePath };
53​
54interface CheckoutEntry {
55 readonly request: BranchCheckoutRequest;
56 readonly checkout: Promise<BranchCheckoutResult>;
57 readonly release: () => void;
58 abandoned: boolean;
60​
61/**
62 * Branch checkouts for draft routes. A checkout is a Git mutation, so it is
63 * tied to the request, not to an atom node: the node can be removed and rebuilt
64 * (a render read before the subscription commits) without running it again.
65 */
66export function makeNewTaskBranchCheckouts(deps: {
67 readonly run: (request: BranchCheckoutRequest) => Promise<BranchCheckoutResult>;
68 /** Holds navigation until the checkout settles and its route is released. */
69 readonly holdNavigation: (operation: Promise<unknown>) => void;
70 /** Runs once the route is released, unless the route was abandoned first. */
71 readonly onFailure: (request: BranchCheckoutRequest, result: BranchCheckoutFailure) => void;
72}) {
73 // Ignoring a stale result cannot undo its Git mutation, so checkouts run one
74 // at a time across routes.
75 let tail: Promise<unknown> = Promise.resolve();
76 // Registered when a checkout starts, never from render.
77 const entries = new Map<string, CheckoutEntry>();
78​
79 function abandon(key: string, entry: CheckoutEntry): void {
80 entry.abandoned = true;
81 entry.release();
82 entries.delete(key);
83 }
84​
85 function start(key: string): Promise<BranchCheckoutResult> {
86 const existing = entries.get(key);
87 if (existing) return existing.checkout;
88 const request = requestFromKey(key);
89 // A route asks for one checkout at a time; a superseded request no longer
90 // owns the route's failure handling or holds navigation.
91 for (const [otherKey, other] of entries) {
92 if (other.request.flowKey === request.flowKey) abandon(otherKey, other);
93 }
94 const released = Promise.withResolvers<void>();
95 const checkout = tail.then(() => deps.run(request));
96 tail = checkout.catch(() => undefined);
97 const entry: CheckoutEntry = { request, checkout, release: released.resolve, abandoned: false };
98 entries.set(key, entry);
99 const handled = checkout.then(async (result) => {
100 if (result._tag === "Success") return;
101 await released.promise;
102 if (!entry.abandoned) deps.onFailure(request, result);
103 });
104 // Navigation waits for the failure handling too, so its goBack lands first.
105 deps.holdNavigation(Promise.allSettled([handled, released.promise]));
106 return checkout;
107 }
108​
109 return {
110 atom: Atom.family((key: string) =>
111 Atom.make(Effect.promise(() => start(key))).pipe(
112 Atom.withLabel("mobile:new-task-branch-checkout"),
113 ),
114 ),
115 /** The route rendered with removal unblocked, so navigation may proceed. */
116 release(key: string): void {
117 entries.get(key)?.release();
118 },
119 /** Abandons the checkouts of draft routes that are no longer on the stack. */
120 prune(liveFlowKeys: ReadonlySet<string>): void {
121 for (const [key, entry] of entries) {
122 if (!liveFlowKeys.has(entry.request.flowKey)) abandon(key, entry);
123 }
124 },
125 };