| author | |
| committer | |
| log | 845ddd9354538d42db1cc7dc30143c6bb8f0d40f |
| tree | 743789899195f84aa97f053c612efb0a381b1c8a |
| parent | 60a56f11e73dfc9c26e74f8f8e93e391191fc3f8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 3 | 3 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 4 | 4 | import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 5 | import * as Cause from "effect/Cause"; | |
| 6 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 7 | 5 | import { LegendList } from "@legendapp/list/react-native"; |
| 8 | 6 | import { |
| 9 | 7 | isAtomCommandInterrupted, |
| ... | ... | @@ -31,7 +29,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym |
| 31 | 29 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 32 | 30 | import { cn } from "../../lib/cn"; |
| 33 | 31 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 34 | import { useServerConfigs, waitForProject } from "../../state/entities"; | |
| 32 | import { useServerConfigs } from "../../state/entities"; | |
| 35 | 33 | import { projectEnvironment } from "../../state/projects"; |
| 36 | 34 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 37 | 35 | import { vcsEnvironment } from "../../state/vcs"; |
| ... | ... | @@ -208,7 +206,7 @@ export function NewTaskEnvironmentPickerRouteScreen() { |
| 208 | 206 | const navigation = useNavigation(); |
| 209 | 207 | const insets = useSafeAreaInsets(); |
| 210 | 208 | const serverConfigs = useServerConfigs(); |
| 211 | const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { | |
| 209 | const openScratch = useAtomCommand(projectEnvironment.openScratch, { | |
| 212 | 210 | reportFailure: false, |
| 213 | 211 | }); |
| 214 | 212 | const [movingToEnvironmentId, setMovingToEnvironmentId] = useState<EnvironmentId | null>(null); |
| ... | ... | @@ -218,23 +216,20 @@ export function NewTaskEnvironmentPickerRouteScreen() { |
| 218 | 216 | async function moveScratchDraft(environmentId: EnvironmentId): Promise<void> { |
| 219 | 217 | setMovingToEnvironmentId(environmentId); |
| 220 | 218 | try { |
| 221 | const result = await ensureScratch({ environmentId, input: {} }); | |
| 222 | if (AsyncResult.isFailure(result)) { | |
| 223 | const error = Cause.squash(result.cause); | |
| 224 | Alert.alert( | |
| 225 | "Could not switch machine", | |
| 226 | error instanceof Error | |
| 227 | ? error.message | |
| 228 | : "The folder for threads without a project could not be created.", | |
| 229 | ); | |
| 230 | return; | |
| 231 | } | |
| 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."); | |
| 219 | const result = await openScratch({ environmentId, input: {} }); | |
| 220 | if (result._tag === "Failure") { | |
| 221 | if (!isAtomCommandInterrupted(result)) { | |
| 222 | const error = squashAtomCommandFailure(result); | |
| 223 | Alert.alert( | |
| 224 | "Could not switch machine", | |
| 225 | error instanceof Error | |
| 226 | ? error.message | |
| 227 | : "The folder for threads without a project could not be created.", | |
| 228 | ); | |
| 229 | } | |
| 235 | 230 | return; |
| 236 | 231 | } |
| 237 | flow.setProject(project); | |
| 232 | flow.setProject(result.value); | |
| 238 | 233 | navigation.goBack(); |
| 239 | 234 | } finally { |
| 240 | 235 | setMovingToEnvironmentId(null); |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+23-25| ... | ... | @@ -7,11 +7,13 @@ import { |
| 7 | 7 | type StaticScreenProps, |
| 8 | 8 | } from "@react-navigation/native"; |
| 9 | 9 | import { SymbolView } from "../../components/AppSymbol"; |
| 10 | import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; | |
| 10 | import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; | |
| 11 | 11 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 12 | import { | |
| 13 | isAtomCommandInterrupted, | |
| 14 | squashAtomCommandFailure, | |
| 15 | } from "@t3tools/client-runtime/state/runtime"; | |
| 12 | 16 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 13 | import * as Cause from "effect/Cause"; | |
| 14 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 15 | 17 | import { useEffect, useRef, useState } from "react"; |
| 16 | 18 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; |
| 17 | 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | ... | @@ -21,7 +23,7 @@ import { MaterialButton } from "../../components/MaterialButton"; |
| 21 | 23 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 22 | 24 | import { AppText as Text } from "../../components/AppText"; |
| 23 | 25 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 24 | import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; | |
| 26 | import { useProjects, useServerConfigs } from "../../state/entities"; | |
| 25 | 27 | import { projectEnvironment } from "../../state/projects"; |
| 26 | 28 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 27 | 29 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| ... | ... | @@ -173,7 +175,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 173 | 175 | ) ?? null) |
| 174 | 176 | : null; |
| 175 | 177 | const { connectedEnvironments } = useRemoteConnectionStatus(); |
| 176 | const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { | |
| 178 | const openScratch = useAtomCommand(projectEnvironment.openScratch, { | |
| 177 | 179 | reportFailure: false, |
| 178 | 180 | }); |
| 179 | 181 | // Threads without a project need a connected environment that offers them. |
| ... | ... | @@ -181,8 +183,10 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 181 | 183 | // first that does; the draft page's machine picker moves it from there. |
| 182 | 184 | const scratchEnvironments = connectedEnvironments.filter( |
| 183 | 185 | (environment) => |
| 184 | canCreateProjectInEnvironment(environment.connectionState) && | |
| 185 | serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, | |
| 186 | availableScratchWorkspaceRoot( | |
| 187 | environment.connectionState, | |
| 188 | serverConfigs.get(environment.environmentId), | |
| 189 | ) !== null, | |
| 186 | 190 | ); |
| 187 | 191 | const scratchEnvironment = |
| 188 | 192 | scratchEnvironments.find( |
| ... | ... | @@ -230,26 +234,20 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 230 | 234 | const environmentId = scratchEnvironment.environmentId; |
| 231 | 235 | scratchStartInFlightRef.current = true; |
| 232 | 236 | try { |
| 233 | const result = await ensureScratch({ environmentId, input: {} }); | |
| 234 | if (AsyncResult.isFailure(result)) { | |
| 235 | const error = Cause.squash(result.cause); | |
| 236 | Alert.alert( | |
| 237 | "Could not start without a project", | |
| 238 | error instanceof Error | |
| 239 | ? error.message | |
| 240 | : "The folder for threads without a project could not be created.", | |
| 241 | ); | |
| 242 | return; | |
| 243 | } | |
| 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 | ); | |
| 237 | const result = await openScratch({ environmentId, input: {} }); | |
| 238 | if (result._tag === "Failure") { | |
| 239 | if (!isAtomCommandInterrupted(result)) { | |
| 240 | const error = squashAtomCommandFailure(result); | |
| 241 | Alert.alert( | |
| 242 | "Could not start without a project", | |
| 243 | error instanceof Error | |
| 244 | ? error.message | |
| 245 | : "The folder for threads without a project could not be created.", | |
| 246 | ); | |
| 247 | } | |
| 250 | 248 | return; |
| 251 | 249 | } |
| 252 | await selectProject(project); | |
| 250 | await selectProject(result.value); | |
| 253 | 251 | } finally { |
| 254 | 252 | scratchStartInFlightRef.current = false; |
| 255 | 253 | } |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+5-3| ... | ... | @@ -89,7 +89,7 @@ import { |
| 89 | 89 | useRemoteConnectionStatus, |
| 90 | 90 | useSavedRemoteConnections, |
| 91 | 91 | } from "../../state/use-remote-environment-registry"; |
| 92 | import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; | |
| 92 | import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; | |
| 93 | 93 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 94 | 94 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 95 | 95 | import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| ... | ... | @@ -367,8 +367,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 367 | 367 | () => |
| 368 | 368 | connectedEnvironments.filter( |
| 369 | 369 | (environment) => |
| 370 | canCreateProjectInEnvironment(environment.connectionState) && | |
| 371 | serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, | |
| 370 | availableScratchWorkspaceRoot( | |
| 371 | environment.connectionState, | |
| 372 | serverConfigs.get(environment.environmentId), | |
| 373 | ) !== null, | |
| 372 | 374 | ), |
| 373 | 375 | [connectedEnvironments, serverConfigs], |
| 374 | 376 | ); |
apps/mobile/src/state/projects.ts+3-1| ... | ... | @@ -5,8 +5,10 @@ import { environmentCatalog } from "../connection/catalog"; |
| 5 | 5 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 6 | 6 | import { environmentSnapshotAtom } from "./shell"; |
| 7 | 7 | |
| 8 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); | |
| 9 | 8 | export const environmentProjects = createEnvironmentProjectAtoms({ |
| 10 | 9 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 11 | 10 | snapshotAtom: environmentSnapshotAtom, |
| 12 | 11 | }); |
| 12 | export 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 | 4396 | } |
| 4397 | 4397 | // Keep send disabled until the destination Scratch project is ready. |
| 4398 | 4398 | setIsEnvironmentChanging(true); |
| 4399 | void openScratchProject(target.environmentId) | |
| 4399 | void openScratchProject(target.environmentId, "Could not switch machine") | |
| 4400 | 4400 | .then((project) => { |
| 4401 | 4401 | if (project) retarget(project); |
| 4402 | 4402 | }) |
apps/web/src/hooks/useScratchProject.ts+19-24| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; | |
| 2 | 3 | import { |
| 3 | 4 | isAtomCommandInterrupted, |
| 4 | 5 | squashAtomCommandFailure, |
| ... | ... | @@ -8,17 +9,16 @@ import type { EnvironmentId } from "@t3tools/contracts"; |
| 8 | 9 | import { useCallback } from "react"; |
| 9 | 10 | |
| 10 | 11 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 11 | import { waitForProject } from "~/state/entities"; | |
| 12 | 12 | import { useEnvironments } from "~/state/environments"; |
| 13 | 13 | import { projectEnvironment } from "~/state/projects"; |
| 14 | 14 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 15 | 15 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 16 | 16 | |
| 17 | function reportScratchFailure(error: unknown) { | |
| 17 | function reportScratchFailure(title: string, error: unknown) { | |
| 18 | 18 | toastManager.add( |
| 19 | 19 | stackedThreadToast({ |
| 20 | 20 | type: "error", |
| 21 | title: "Could not start without a project", | |
| 21 | title, | |
| 22 | 22 | description: error instanceof Error ? error.message : "An error occurred.", |
| 23 | 23 | }), |
| 24 | 24 | ); |
| ... | ... | @@ -32,16 +32,17 @@ function reportScratchFailure(error: unknown) { |
| 32 | 32 | */ |
| 33 | 33 | export function useScratchProject() { |
| 34 | 34 | const { environments } = useEnvironments(); |
| 35 | const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false }); | |
| 35 | const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); | |
| 36 | 36 | const handleNewThread = useNewThreadHandler(); |
| 37 | 37 | |
| 38 | 38 | /** The scratch folder of a connected environment, or null when it offers none. */ |
| 39 | 39 | const scratchWorkspaceRootFor = useCallback( |
| 40 | 40 | (environmentId: EnvironmentId | null): string | null => { |
| 41 | 41 | const environment = environments.find((entry) => entry.environmentId === environmentId); |
| 42 | return environment?.connection.phase === "connected" | |
| 43 | ? (environment.serverConfig?.scratchWorkspaceRoot ?? null) | |
| 44 | : null; | |
| 42 | return availableScratchWorkspaceRoot( | |
| 43 | environment?.connection.phase, | |
| 44 | environment?.serverConfig, | |
| 45 | ); | |
| 45 | 46 | }, |
| 46 | 47 | [environments], |
| 47 | 48 | ); |
| ... | ... | @@ -62,24 +63,18 @@ export function useScratchProject() { |
| 62 | 63 | |
| 63 | 64 | /** Resolves to the scratch project once it is in this client's store. */ |
| 64 | 65 | const openScratchProject = useCallback( |
| 65 | async (environmentId: EnvironmentId): Promise<EnvironmentProject | null> => { | |
| 66 | const result = await ensureScratch({ environmentId, input: {} }); | |
| 67 | if (result._tag === "Failure") { | |
| 68 | if (!isAtomCommandInterrupted(result)) { | |
| 69 | reportScratchFailure(squashAtomCommandFailure(result)); | |
| 70 | } | |
| 71 | return null; | |
| 66 | async ( | |
| 67 | environmentId: EnvironmentId, | |
| 68 | failureTitle = "Could not start without a project", | |
| 69 | ): Promise<EnvironmentProject | null> => { | |
| 70 | const result = await openScratch({ environmentId, input: {} }); | |
| 71 | if (result._tag === "Success") return result.value; | |
| 72 | if (!isAtomCommandInterrupted(result)) { | |
| 73 | reportScratchFailure(failureTitle, squashAtomCommandFailure(result)); | |
| 72 | 74 | } |
| 73 | // Drafts key off the project's stored path and settings, so wait for | |
| 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 | ); | |
| 75 | return null; | |
| 81 | 76 | }, |
| 82 | [ensureScratch], | |
| 77 | [openScratch], | |
| 83 | 78 | ); |
| 84 | 79 | |
| 85 | 80 | const startScratchThread = useCallback( |
| ... | ... | @@ -87,7 +82,7 @@ export function useScratchProject() { |
| 87 | 82 | const project = await openScratchProject(environmentId); |
| 88 | 83 | if (project) { |
| 89 | 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 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | 8 | import { environmentSnapshotAtom } from "./shell"; |
| 9 | 9 | |
| 10 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); | |
| 10 | export const environmentProjects = createEnvironmentProjectAtoms({ | |
| 11 | catalogValueAtom: environmentCatalog.catalogValueAtom, | |
| 12 | snapshotAtom: environmentSnapshotAtom, | |
| 13 | }); | |
| 14 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { | |
| 15 | projectAtom: environmentProjects.projectAtom, | |
| 16 | }); | |
| 11 | 17 | /** |
| 12 | 18 | * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile |
| 13 | 19 | * surface, so the atom family lives here instead of the shared client-runtime |
| ... | ... | @@ -19,7 +25,3 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti |
| 19 | 25 | staleTimeMs: 5_000, |
| 20 | 26 | idleTtlMs: 60_000, |
| 21 | 27 | }); |
| 22 | export 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 | 19 | start without a project** under a new thread's heading, pick **No project** from |
| 20 | 20 | the project menu in that heading or from **New thread in...** in the command |
| 21 | 21 | palette, or press `mod+alt+n`. On mobile, pick **No project** from the project |
| 22 | list. To move a draft into a project, pick the project in the heading. | |
| 22 | list. It starts on your current machine; before sending, pick another machine | |
| 23 | from the machine menu to move it there. To move a draft into a project, pick the | |
| 24 | project in the heading. | |
| 23 | 25 | |
| 24 | 26 | Each thread without a project works in its own folder under `~/.t3/scratch` (the |
| 25 | 27 | `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 | 4 | EnvironmentId, |
| 5 | 5 | ProjectMutation, |
| 6 | 6 | ProjectId, |
| 7 | ServerConfig, | |
| 7 | 8 | SourceControlDiscoveryResult, |
| 8 | 9 | SourceControlProviderKind, |
| 9 | 10 | SourceControlRepositoryInfo, |
| ... | ... | @@ -36,6 +37,19 @@ export function canCreateProjectInEnvironment( |
| 36 | 37 | return connectionPhase === "connected"; |
| 37 | 38 | } |
| 38 | 39 | |
| 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 | */ | |
| 44 | export 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 | ||
| 39 | 53 | export type AddProjectRemoteSourceReadiness = Record< |
| 40 | 54 | AddProjectRemoteSource, |
| 41 | 55 | { readonly ready: boolean; readonly hint: string | null } |
packages/client-runtime/src/state/projectCommands.test.ts created+77| ... | ... | @@ -0,0 +1,77 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { EnvironmentId, ProjectId, type ScopedProjectRef, WS_METHODS } from "@t3tools/contracts"; | |
| 3 | import * as Crypto from "effect/Crypto"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 9 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 10 | ||
| 11 | import * as EnvironmentRegistry from "../connection/registry.ts"; | |
| 12 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | |
| 13 | import type { RpcSession } from "../rpc/session.ts"; | |
| 14 | import type { EnvironmentProject } from "./models.ts"; | |
| 15 | import { createProjectEnvironmentAtoms } from "./projectCommands.ts"; | |
| 16 | ||
| 17 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | |
| 18 | const PROJECT_ID = ProjectId.make("scratch"); | |
| 19 | const PROJECT = { | |
| 20 | id: PROJECT_ID, | |
| 21 | environmentId: ENVIRONMENT_ID, | |
| 22 | workspaceRoot: "/scratch", | |
| 23 | } as EnvironmentProject; | |
| 24 | ||
| 25 | const 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 | ||
| 66 | describe("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 | import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | type EnvironmentId, | |
| 3 | ProjectId, | |
| 4 | type ProjectReadFileResult, | |
| 5 | type ScopedProjectRef, | |
| 6 | WS_METHODS, | |
| 7 | } from "@t3tools/contracts"; | |
| 2 | 8 | import * as Crypto from "effect/Crypto"; |
| 3 | import { Atom } from "effect/unstable/reactivity"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Predicate from "effect/Predicate"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | import * as Stream from "effect/Stream"; | |
| 14 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 4 | 15 | |
| 16 | import { type EnvironmentRpcInput, request } from "../rpc/client.ts"; | |
| 17 | import type { EnvironmentProject } from "./models.ts"; | |
| 5 | 18 | import { |
| 6 | 19 | createAtomCommandScheduler, |
| 7 | 20 | createEnvironmentCommand, |
| ... | ... | @@ -39,8 +52,22 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { |
| 39 | 52 | return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); |
| 40 | 53 | } |
| 41 | 54 | |
| 55 | /** The Scratch project was created, but its event never reached this client. */ | |
| 56 | export 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 | ||
| 42 | 65 | export function createProjectEnvironmentAtoms<R, E>( |
| 43 | 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 | 72 | const projectScheduler = createAtomCommandScheduler(); |
| 46 | 73 | const fileScheduler = createAtomCommandScheduler(); |
| ... | ... | @@ -92,10 +119,31 @@ export function createProjectEnvironmentAtoms<R, E>( |
| 92 | 119 | scheduler: projectScheduler, |
| 93 | 120 | concurrency: projectConcurrency, |
| 94 | 121 | }), |
| 95 | // Finds or creates the environment's Scratch project and returns its id. | |
| 96 | ensureScratch: createEnvironmentRpcCommand(runtime, { | |
| 97 | label: "environment-data:projects:ensure-scratch", | |
| 98 | tag: WS_METHODS.projectsEnsureScratch, | |
| 122 | // Finds or creates the environment's Scratch project and resolves once the | |
| 123 | // project is in the client store, since drafts key off its stored path. | |
| 124 | openScratch: createEnvironmentCommand(runtime, { | |
| 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 | 147 | scheduler: projectScheduler, |
| 100 | 148 | concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, |
| 101 | 149 | }), |