authorgravatar for 45952064+bmdavis419@users.noreply.github.comBen Davis <45952064+bmdavis419@users.noreply.github.com> 2026-10-03 21:38:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 21:38:09-07:00
log845ddd9354538d42db1cc7dc30143c6bb8f0d40f
tree743789899195f84aa97f053c612efb0a381b1c8a
parent60a56f11e73dfc9c26e74f8f8e93e391191fc3f8
signature Signed by PGP key B5690EEEBB952194

refactor(clients): share opening a machine's No project folder (#14759)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

11 files changed, 220 insertions(+), 85 deletions(-)

apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+14-19
...@@ -2,8 +2,6 @@ import { MaterialListRow } from "../../components/MaterialListRow";...@@ -2,8 +2,6 @@ import { MaterialListRow } from "../../components/MaterialListRow";
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
3import type { VcsRef } from "@t3tools/client-runtime/state/vcs";3import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
4import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";4import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
5import * as Cause from "effect/Cause";
6import { AsyncResult } from "effect/unstable/reactivity";
7import { LegendList } from "@legendapp/list/react-native";5import { LegendList } from "@legendapp/list/react-native";
8import {6import {
9 isAtomCommandInterrupted,7 isAtomCommandInterrupted,
...@@ -31,7 +29,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym...@@ -31,7 +29,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym
31import { ThemedSwitch } from "../../components/ThemedSwitch";29import { ThemedSwitch } from "../../components/ThemedSwitch";
32import { cn } from "../../lib/cn";30import { cn } from "../../lib/cn";
33import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";31import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
34import { useServerConfigs, waitForProject } from "../../state/entities";32import { useServerConfigs } from "../../state/entities";
35import { projectEnvironment } from "../../state/projects";33import { projectEnvironment } from "../../state/projects";
36import { useAtomCommand } from "../../state/use-atom-command";34import { useAtomCommand } from "../../state/use-atom-command";
37import { vcsEnvironment } from "../../state/vcs";35import { vcsEnvironment } from "../../state/vcs";
...@@ -208,7 +206,7 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -208,7 +206,7 @@ export function NewTaskEnvironmentPickerRouteScreen() {
208 const navigation = useNavigation();206 const navigation = useNavigation();
209 const insets = useSafeAreaInsets();207 const insets = useSafeAreaInsets();
210 const serverConfigs = useServerConfigs();208 const serverConfigs = useServerConfigs();
211 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {209 const openScratch = useAtomCommand(projectEnvironment.openScratch, {
212 reportFailure: false,210 reportFailure: false,
213 });211 });
214 const [movingToEnvironmentId, setMovingToEnvironmentId] = useState<EnvironmentId | null>(null);212 const [movingToEnvironmentId, setMovingToEnvironmentId] = useState<EnvironmentId | null>(null);
...@@ -218,23 +216,20 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -218,23 +216,20 @@ export function NewTaskEnvironmentPickerRouteScreen() {
218 async function moveScratchDraft(environmentId: EnvironmentId): Promise<void> {216 async function moveScratchDraft(environmentId: EnvironmentId): Promise<void> {
219 setMovingToEnvironmentId(environmentId);217 setMovingToEnvironmentId(environmentId);
220 try {218 try {
221 const result = await ensureScratch({ environmentId, input: {} });219 const result = await openScratch({ environmentId, input: {} });
222 if (AsyncResult.isFailure(result)) {220 if (result._tag === "Failure") {
223 const error = Cause.squash(result.cause);221 if (!isAtomCommandInterrupted(result)) {
224 Alert.alert(222 const error = squashAtomCommandFailure(result);
225 "Could not switch machine",223 Alert.alert(
226 error instanceof Error224 "Could not switch machine",
227 ? error.message225 error instanceof Error
228 : "The folder for threads without a project could not be created.",226 ? error.message
229 );227 : "The folder for threads without a project could not be created.",
230 return;228 );
231 }229 }
232 const project = await waitForProject({ environmentId, projectId: result.value.projectId });
233 if (project === null) {
234 Alert.alert("Could not switch machine", "It has not reached this device yet. Try again.");
235 return;230 return;
236 }231 }
237 flow.setProject(project);232 flow.setProject(result.value);
238 navigation.goBack();233 navigation.goBack();
239 } finally {234 } finally {
240 setMovingToEnvironmentId(null);235 setMovingToEnvironmentId(null);
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+23-25
...@@ -7,11 +7,13 @@ import {...@@ -7,11 +7,13 @@ import {
7 type StaticScreenProps,7 type StaticScreenProps,
8} from "@react-navigation/native";8} from "@react-navigation/native";
9import { SymbolView } from "../../components/AppSymbol";9import { SymbolView } from "../../components/AppSymbol";
10import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";10import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
11import { isScratchProject } from "@t3tools/client-runtime/state/projects";11import { isScratchProject } from "@t3tools/client-runtime/state/projects";
12import {
13 isAtomCommandInterrupted,
14 squashAtomCommandFailure,
15} from "@t3tools/client-runtime/state/runtime";
12import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";16import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
13import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/unstable/reactivity";
15import { useEffect, useRef, useState } from "react";17import { useEffect, useRef, useState } from "react";
16import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";18import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
17import { useSafeAreaInsets } from "react-native-safe-area-context";19import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -21,7 +23,7 @@ import { MaterialButton } from "../../components/MaterialButton";...@@ -21,7 +23,7 @@ import { MaterialButton } from "../../components/MaterialButton";
21import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";23import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
22import { AppText as Text } from "../../components/AppText";24import { AppText as Text } from "../../components/AppText";
23import { ProjectFavicon } from "../../components/ProjectFavicon";25import { ProjectFavicon } from "../../components/ProjectFavicon";
24import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";26import { useProjects, useServerConfigs } from "../../state/entities";
25import { projectEnvironment } from "../../state/projects";27import { projectEnvironment } from "../../state/projects";
26import { useAtomCommand } from "../../state/use-atom-command";28import { useAtomCommand } from "../../state/use-atom-command";
27import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";29import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
...@@ -173,7 +175,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -173,7 +175,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
173 ) ?? null)175 ) ?? null)
174 : null;176 : null;
175 const { connectedEnvironments } = useRemoteConnectionStatus();177 const { connectedEnvironments } = useRemoteConnectionStatus();
176 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {178 const openScratch = useAtomCommand(projectEnvironment.openScratch, {
177 reportFailure: false,179 reportFailure: false,
178 });180 });
179 // Threads without a project need a connected environment that offers them.181 // Threads without a project need a connected environment that offers them.
...@@ -181,8 +183,10 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -181,8 +183,10 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
181 // first that does; the draft page's machine picker moves it from there.183 // first that does; the draft page's machine picker moves it from there.
182 const scratchEnvironments = connectedEnvironments.filter(184 const scratchEnvironments = connectedEnvironments.filter(
183 (environment) =>185 (environment) =>
184 canCreateProjectInEnvironment(environment.connectionState) &&186 availableScratchWorkspaceRoot(
185 serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined,187 environment.connectionState,
188 serverConfigs.get(environment.environmentId),
189 ) !== null,
186 );190 );
187 const scratchEnvironment =191 const scratchEnvironment =
188 scratchEnvironments.find(192 scratchEnvironments.find(
...@@ -230,26 +234,20 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -230,26 +234,20 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
230 const environmentId = scratchEnvironment.environmentId;234 const environmentId = scratchEnvironment.environmentId;
231 scratchStartInFlightRef.current = true;235 scratchStartInFlightRef.current = true;
232 try {236 try {
233 const result = await ensureScratch({ environmentId, input: {} });237 const result = await openScratch({ environmentId, input: {} });
234 if (AsyncResult.isFailure(result)) {238 if (result._tag === "Failure") {
235 const error = Cause.squash(result.cause);239 if (!isAtomCommandInterrupted(result)) {
236 Alert.alert(240 const error = squashAtomCommandFailure(result);
237 "Could not start without a project",241 Alert.alert(
238 error instanceof Error242 "Could not start without a project",
239 ? error.message243 error instanceof Error
240 : "The folder for threads without a project could not be created.",244 ? error.message
241 );245 : "The folder for threads without a project could not be created.",
242 return;246 );
243 }247 }
244 const project = await waitForProject({ environmentId, projectId: result.value.projectId });
245 if (project === null) {
246 Alert.alert(
247 "Could not start without a project",
248 "It has not reached this device yet. Pick No project from the list once it appears.",
249 );
250 return;248 return;
251 }249 }
252 await selectProject(project);250 await selectProject(result.value);
253 } finally {251 } finally {
254 scratchStartInFlightRef.current = false;252 scratchStartInFlightRef.current = false;
255 }253 }
apps/mobile/src/features/threads/new-task-flow-provider.tsx+5-3
...@@ -89,7 +89,7 @@ import {...@@ -89,7 +89,7 @@ import {
89 useRemoteConnectionStatus,89 useRemoteConnectionStatus,
90 useSavedRemoteConnections,90 useSavedRemoteConnections,
91} from "../../state/use-remote-environment-registry";91} from "../../state/use-remote-environment-registry";
92import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";92import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
93import { isScratchProject } from "@t3tools/client-runtime/state/projects";93import { isScratchProject } from "@t3tools/client-runtime/state/projects";
94import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";94import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
95import { type VcsRef } from "@t3tools/client-runtime/state/vcs";95import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
...@@ -367,8 +367,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -367,8 +367,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
367 () =>367 () =>
368 connectedEnvironments.filter(368 connectedEnvironments.filter(
369 (environment) =>369 (environment) =>
370 canCreateProjectInEnvironment(environment.connectionState) &&370 availableScratchWorkspaceRoot(
371 serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined,371 environment.connectionState,
372 serverConfigs.get(environment.environmentId),
373 ) !== null,
372 ),374 ),
373 [connectedEnvironments, serverConfigs],375 [connectedEnvironments, serverConfigs],
374 );376 );
apps/mobile/src/state/projects.ts+3-1
...@@ -5,8 +5,10 @@ import { environmentCatalog } from "../connection/catalog";...@@ -5,8 +5,10 @@ import { environmentCatalog } from "../connection/catalog";
5import { connectionAtomRuntime } from "../connection/runtime";5import { connectionAtomRuntime } from "../connection/runtime";
6import { environmentSnapshotAtom } from "./shell";6import { environmentSnapshotAtom } from "./shell";
77
8export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime);
9export const environmentProjects = createEnvironmentProjectAtoms({8export const environmentProjects = createEnvironmentProjectAtoms({
10 catalogValueAtom: environmentCatalog.catalogValueAtom,9 catalogValueAtom: environmentCatalog.catalogValueAtom,
11 snapshotAtom: environmentSnapshotAtom,10 snapshotAtom: environmentSnapshotAtom,
12});11});
12export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, {
13 projectAtom: environmentProjects.projectAtom,
14});
apps/web/src/components/ChatView.tsx+1-1
...@@ -4396,7 +4396,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4396,7 +4396,7 @@ export default function ChatView(props: ChatViewProps) {
4396 }4396 }
4397 // Keep send disabled until the destination Scratch project is ready.4397 // Keep send disabled until the destination Scratch project is ready.
4398 setIsEnvironmentChanging(true);4398 setIsEnvironmentChanging(true);
4399 void openScratchProject(target.environmentId)4399 void openScratchProject(target.environmentId, "Could not switch machine")
4400 .then((project) => {4400 .then((project) => {
4401 if (project) retarget(project);4401 if (project) retarget(project);
4402 })4402 })
apps/web/src/hooks/useScratchProject.ts+19-24
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
2import {3import {
3 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
4 squashAtomCommandFailure,5 squashAtomCommandFailure,
...@@ -8,17 +9,16 @@ import type { EnvironmentId } from "@t3tools/contracts";...@@ -8,17 +9,16 @@ import type { EnvironmentId } from "@t3tools/contracts";
8import { useCallback } from "react";9import { useCallback } from "react";
910
10import { stackedThreadToast, toastManager } from "~/components/ui/toast";11import { stackedThreadToast, toastManager } from "~/components/ui/toast";
11import { waitForProject } from "~/state/entities";
12import { useEnvironments } from "~/state/environments";12import { useEnvironments } from "~/state/environments";
13import { projectEnvironment } from "~/state/projects";13import { projectEnvironment } from "~/state/projects";
14import { useAtomCommand } from "~/state/use-atom-command";14import { useAtomCommand } from "~/state/use-atom-command";
15import { useNewThreadHandler } from "./useHandleNewThread";15import { useNewThreadHandler } from "./useHandleNewThread";
1616
17function reportScratchFailure(error: unknown) {17function reportScratchFailure(title: string, error: unknown) {
18 toastManager.add(18 toastManager.add(
19 stackedThreadToast({19 stackedThreadToast({
20 type: "error",20 type: "error",
21 title: "Could not start without a project",21 title,
22 description: error instanceof Error ? error.message : "An error occurred.",22 description: error instanceof Error ? error.message : "An error occurred.",
23 }),23 }),
24 );24 );
...@@ -32,16 +32,17 @@ function reportScratchFailure(error: unknown) {...@@ -32,16 +32,17 @@ function reportScratchFailure(error: unknown) {
32 */32 */
33export function useScratchProject() {33export function useScratchProject() {
34 const { environments } = useEnvironments();34 const { environments } = useEnvironments();
35 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false });35 const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
36 const handleNewThread = useNewThreadHandler();36 const handleNewThread = useNewThreadHandler();
3737
38 /** The scratch folder of a connected environment, or null when it offers none. */38 /** The scratch folder of a connected environment, or null when it offers none. */
39 const scratchWorkspaceRootFor = useCallback(39 const scratchWorkspaceRootFor = useCallback(
40 (environmentId: EnvironmentId | null): string | null => {40 (environmentId: EnvironmentId | null): string | null => {
41 const environment = environments.find((entry) => entry.environmentId === environmentId);41 const environment = environments.find((entry) => entry.environmentId === environmentId);
42 return environment?.connection.phase === "connected"42 return availableScratchWorkspaceRoot(
43 ? (environment.serverConfig?.scratchWorkspaceRoot ?? null)43 environment?.connection.phase,
44 : null;44 environment?.serverConfig,
45 );
45 },46 },
46 [environments],47 [environments],
47 );48 );
...@@ -62,24 +63,18 @@ export function useScratchProject() {...@@ -62,24 +63,18 @@ export function useScratchProject() {
6263
63 /** Resolves to the scratch project once it is in this client's store. */64 /** Resolves to the scratch project once it is in this client's store. */
64 const openScratchProject = useCallback(65 const openScratchProject = useCallback(
65 async (environmentId: EnvironmentId): Promise<EnvironmentProject | null> => {66 async (
66 const result = await ensureScratch({ environmentId, input: {} });67 environmentId: EnvironmentId,
67 if (result._tag === "Failure") {68 failureTitle = "Could not start without a project",
68 if (!isAtomCommandInterrupted(result)) {69 ): Promise<EnvironmentProject | null> => {
69 reportScratchFailure(squashAtomCommandFailure(result));70 const result = await openScratch({ environmentId, input: {} });
70 }71 if (result._tag === "Success") return result.value;
71 return null;72 if (!isAtomCommandInterrupted(result)) {
73 reportScratchFailure(failureTitle, squashAtomCommandFailure(result));
72 }74 }
73 // Drafts key off the project's stored path and settings, so wait for75 return null;
74 // the create event to reach the store before targeting one.
75 return waitForProject(scopeProjectRef(environmentId, result.value.projectId)).catch(
76 (error: unknown) => {
77 reportScratchFailure(error);
78 return null;
79 },
80 );
81 },76 },
82 [ensureScratch],77 [openScratch],
83 );78 );
8479
85 const startScratchThread = useCallback(80 const startScratchThread = useCallback(
...@@ -87,7 +82,7 @@ export function useScratchProject() {...@@ -87,7 +82,7 @@ export function useScratchProject() {
87 const project = await openScratchProject(environmentId);82 const project = await openScratchProject(environmentId);
88 if (project) {83 if (project) {
89 await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch(84 await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch(
90 reportScratchFailure,85 (error: unknown) => reportScratchFailure("Could not start without a project", error),
91 );86 );
92 }87 }
93 },88 },
apps/web/src/state/projects.ts+7-5
...@@ -7,7 +7,13 @@ import { environmentCatalog } from "../connection/catalog";...@@ -7,7 +7,13 @@ import { environmentCatalog } from "../connection/catalog";
7import { connectionAtomRuntime } from "../connection/runtime";7import { connectionAtomRuntime } from "../connection/runtime";
8import { environmentSnapshotAtom } from "./shell";8import { environmentSnapshotAtom } from "./shell";
99
10export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime);10export const environmentProjects = createEnvironmentProjectAtoms({
11 catalogValueAtom: environmentCatalog.catalogValueAtom,
12 snapshotAtom: environmentSnapshotAtom,
13});
14export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, {
15 projectAtom: environmentProjects.projectAtom,
16});
11/**17/**
12 * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile18 * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile
13 * surface, so the atom family lives here instead of the shared client-runtime19 * surface, so the atom family lives here instead of the shared client-runtime
...@@ -19,7 +25,3 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti...@@ -19,7 +25,3 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti
19 staleTimeMs: 5_000,25 staleTimeMs: 5_000,
20 idleTtlMs: 60_000,26 idleTtlMs: 60_000,
21});27});
22export const environmentProjects = createEnvironmentProjectAtoms({
23 catalogValueAtom: environmentCatalog.catalogValueAtom,
24 snapshotAtom: environmentSnapshotAtom,
25});
docs/user/thread-sidebar.md+3-1
...@@ -19,7 +19,9 @@ A thread does not need a project. To start one without a project, click **or...@@ -19,7 +19,9 @@ A thread does not need a project. To start one without a project, click **or
19start without a project** under a new thread's heading, pick **No project** from19start without a project** under a new thread's heading, pick **No project** from
20the project menu in that heading or from **New thread in...** in the command20the project menu in that heading or from **New thread in...** in the command
21palette, or press `mod+alt+n`. On mobile, pick **No project** from the project21palette, or press `mod+alt+n`. On mobile, pick **No project** from the project
22list. To move a draft into a project, pick the project in the heading.22list. It starts on your current machine; before sending, pick another machine
23from the machine menu to move it there. To move a draft into a project, pick the
24project in the heading.
2325
24Each thread without a project works in its own folder under `~/.t3/scratch` (the26Each thread without a project works in its own folder under `~/.t3/scratch` (the
25`scratch` folder of your T3 data directory), named after its date, the first words27`scratch` folder of your T3 data directory), named after its date, the first words
packages/client-runtime/src/operations/projects.ts+14
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 EnvironmentId,4 EnvironmentId,
5 ProjectMutation,5 ProjectMutation,
6 ProjectId,6 ProjectId,
7 ServerConfig,
7 SourceControlDiscoveryResult,8 SourceControlDiscoveryResult,
8 SourceControlProviderKind,9 SourceControlProviderKind,
9 SourceControlRepositoryInfo,10 SourceControlRepositoryInfo,
...@@ -36,6 +37,19 @@ export function canCreateProjectInEnvironment(...@@ -36,6 +37,19 @@ export function canCreateProjectInEnvironment(
36 return connectionPhase === "connected";37 return connectionPhase === "connected";
37}38}
3839
40/**
41 * The Scratch folder an environment offers threads without a project right
42 * now, or null while it is not connected or has none.
43 */
44export function availableScratchWorkspaceRoot(
45 connectionPhase: EnvironmentConnectionPhase | null | undefined,
46 serverConfig: Pick<ServerConfig, "scratchWorkspaceRoot"> | null | undefined,
47): string | null {
48 return canCreateProjectInEnvironment(connectionPhase)
49 ? (serverConfig?.scratchWorkspaceRoot ?? null)
50 : null;
51}
52
39export type AddProjectRemoteSourceReadiness = Record<53export type AddProjectRemoteSourceReadiness = Record<
40 AddProjectRemoteSource,54 AddProjectRemoteSource,
41 { readonly ready: boolean; readonly hint: string | null }55 { readonly ready: boolean; readonly hint: string | null }
packages/client-runtime/src/state/projectCommands.test.ts created+77
...@@ -0,0 +1,77 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ProjectId, type ScopedProjectRef, WS_METHODS } from "@t3tools/contracts";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9import { Atom, AtomRegistry } from "effect/unstable/reactivity";
10
11import * as EnvironmentRegistry from "../connection/registry.ts";
12import * as EnvironmentSupervisor from "../connection/supervisor.ts";
13import type { RpcSession } from "../rpc/session.ts";
14import type { EnvironmentProject } from "./models.ts";
15import { createProjectEnvironmentAtoms } from "./projectCommands.ts";
16
17const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
18const PROJECT_ID = ProjectId.make("scratch");
19const PROJECT = {
20 id: PROJECT_ID,
21 environmentId: ENVIRONMENT_ID,
22 workspaceRoot: "/scratch",
23} as EnvironmentProject;
24
25const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {
26 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
27 target: { environmentId: ENVIRONMENT_ID },
28 session: yield* SubscriptionRef.make(
29 Option.some({
30 client: {
31 [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }),
32 },
33 } as unknown as RpcSession),
34 ),
35 } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
36 const runtime = Atom.runtime(
37 Layer.mergeAll(
38 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, {
39 run: (_environmentId, effect) =>
40 Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
41 } as EnvironmentRegistry.EnvironmentRegistry["Service"]),
42 Layer.succeed(
43 Crypto.Crypto,
44 Crypto.make({
45 randomBytes: (size) => new Uint8Array(size),
46 digest: (_algorithm, data) => Effect.succeed(data),
47 }),
48 ),
49 ),
50 );
51 const storedProject = Atom.make<EnvironmentProject | null>(null);
52 const commands = createProjectEnvironmentAtoms(runtime, {
53 projectAtom: (ref: ScopedProjectRef) =>
54 ref.environmentId === ENVIRONMENT_ID && ref.projectId === PROJECT_ID
55 ? storedProject
56 : Atom.make(null),
57 });
58 const registry = AtomRegistry.make();
59 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
60 const openScratch = Effect.promise(() =>
61 commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }),
62 );
63 return { registry, storedProject, openScratch };
64});
65
66describe("openScratch", () => {
67 it.effect("resolves once the created project reaches the client store", () =>
68 Effect.gen(function* () {
69 const { registry, storedProject, openScratch } = yield* makeHarness();
70 const opening = yield* Effect.forkChild(openScratch);
71 yield* Effect.yieldNow;
72 registry.set(storedProject, PROJECT);
73 const result = yield* Fiber.join(opening);
74 expect(result).toMatchObject({ _tag: "Success", value: PROJECT });
75 }).pipe(Effect.scoped),
76 );
77});
packages/client-runtime/src/state/projectCommands.ts+54-6
...@@ -1,7 +1,20 @@...@@ -1,7 +1,20 @@
1import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts";1import {
2 type EnvironmentId,
3 ProjectId,
4 type ProjectReadFileResult,
5 type ScopedProjectRef,
6 WS_METHODS,
7} from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";8import * as Crypto from "effect/Crypto";
3import { Atom } from "effect/unstable/reactivity";9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import * as Predicate from "effect/Predicate";
12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";
14import { Atom, AtomRegistry } from "effect/unstable/reactivity";
415
16import { type EnvironmentRpcInput, request } from "../rpc/client.ts";
17import type { EnvironmentProject } from "./models.ts";
5import {18import {
6 createAtomCommandScheduler,19 createAtomCommandScheduler,
7 createEnvironmentCommand,20 createEnvironmentCommand,
...@@ -39,8 +52,22 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string {...@@ -39,8 +52,22 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string {
39 return JSON.stringify([target.environmentId, target.cwd, target.relativePath]);52 return JSON.stringify([target.environmentId, target.cwd, target.relativePath]);
40}53}
4154
55/** The Scratch project was created, but its event never reached this client. */
56export class ScratchProjectNotLoadedError extends Schema.TaggedError<ScratchProjectNotLoadedError>()(
57 "ScratchProjectNotLoadedError",
58 { projectId: ProjectId },
59) {
60 override get message(): string {
61 return "The folder for threads without a project has not reached this device yet. Try again.";
62 }
63}
64
42export function createProjectEnvironmentAtoms<R, E>(65export function createProjectEnvironmentAtoms<R, E>(
43 runtime: Atom.AtomRuntime<EnvironmentRegistry | Crypto.Crypto | R, E>,66 runtime: Atom.AtomRuntime<EnvironmentRegistry | Crypto.Crypto | R, E>,
67 options: {
68 /** The client store's project; openScratch waits here for the created project. */
69 readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom<EnvironmentProject | null>;
70 },
44) {71) {
45 const projectScheduler = createAtomCommandScheduler();72 const projectScheduler = createAtomCommandScheduler();
46 const fileScheduler = createAtomCommandScheduler();73 const fileScheduler = createAtomCommandScheduler();
...@@ -92,10 +119,31 @@ export function createProjectEnvironmentAtoms<R, E>(...@@ -92,10 +119,31 @@ export function createProjectEnvironmentAtoms<R, E>(
92 scheduler: projectScheduler,119 scheduler: projectScheduler,
93 concurrency: projectConcurrency,120 concurrency: projectConcurrency,
94 }),121 }),
95 // Finds or creates the environment's Scratch project and returns its id.122 // Finds or creates the environment's Scratch project and resolves once the
96 ensureScratch: createEnvironmentRpcCommand(runtime, {123 // project is in the client store, since drafts key off its stored path.
97 label: "environment-data:projects:ensure-scratch",124 openScratch: createEnvironmentCommand(runtime, {
98 tag: WS_METHODS.projectsEnsureScratch,125 label: "environment-data:projects:open-scratch",
126 execute: (
127 input: EnvironmentRpcInput<typeof WS_METHODS.projectsEnsureScratch>,
128 registry,
129 environmentId,
130 ) =>
131 request(WS_METHODS.projectsEnsureScratch, input).pipe(
132 Effect.flatMap(({ projectId }) =>
133 AtomRegistry.toStream(registry, options.projectAtom({ environmentId, projectId })).pipe(
134 Stream.filter(Predicate.isNotNull),
135 Stream.runHead,
136 Effect.timeoutOption("10 seconds"),
137 Effect.map(Option.flatten),
138 Effect.flatMap(
139 Option.match({
140 onSome: Effect.succeed,
141 onNone: () => Effect.fail(new ScratchProjectNotLoadedError({ projectId })),
142 }),
143 ),
144 ),
145 ),
146 ),
99 scheduler: projectScheduler,147 scheduler: projectScheduler,
100 concurrency: { mode: "serial", key: ({ environmentId }) => environmentId },148 concurrency: { mode: "serial", key: ({ environmentId }) => environmentId },
101 }),149 }),