| 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,8 +2,6 @@ import { MaterialListRow } from "../../components/MaterialListRow"; |
| 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 3 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; | 3 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 4 | import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; | 4 | import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 5 | import * as Cause from "effect/Cause"; | ||
| 6 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 7 | import { LegendList } from "@legendapp/list/react-native"; | 5 | import { LegendList } from "@legendapp/list/react-native"; |
| 8 | import { | 6 | import { |
| 9 | isAtomCommandInterrupted, | 7 | isAtomCommandInterrupted, |
| ... | @@ -31,7 +29,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym | ... | @@ -31,7 +29,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym |
| 31 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | 29 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 32 | import { cn } from "../../lib/cn"; | 30 | import { cn } from "../../lib/cn"; |
| 33 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 31 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 34 | import { useServerConfigs, waitForProject } from "../../state/entities"; | 32 | import { useServerConfigs } from "../../state/entities"; |
| 35 | import { projectEnvironment } from "../../state/projects"; | 33 | import { projectEnvironment } from "../../state/projects"; |
| 36 | import { useAtomCommand } from "../../state/use-atom-command"; | 34 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 37 | import { vcsEnvironment } from "../../state/vcs"; | 35 | import { 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 Error | 224 | "Could not switch machine", |
| 227 | ? error.message | 225 | 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"; |
| 9 | import { SymbolView } from "../../components/AppSymbol"; | 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 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | 11 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 12 | import { | ||
| 13 | isAtomCommandInterrupted, | ||
| 14 | squashAtomCommandFailure, | ||
| 15 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 12 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 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 | import { useEffect, useRef, useState } from "react"; | 17 | import { useEffect, useRef, useState } from "react"; |
| 16 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; | 18 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; |
| 17 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 19 | import { 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"; |
| 21 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | 23 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 22 | import { AppText as Text } from "../../components/AppText"; | 24 | import { AppText as Text } from "../../components/AppText"; |
| 23 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 25 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 24 | import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; | 26 | import { useProjects, useServerConfigs } from "../../state/entities"; |
| 25 | import { projectEnvironment } from "../../state/projects"; | 27 | import { projectEnvironment } from "../../state/projects"; |
| 26 | import { useAtomCommand } from "../../state/use-atom-command"; | 28 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 27 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | 29 | import { 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 Error | 242 | "Could not start without a project", |
| 239 | ? error.message | 243 | 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"; |
| 92 | import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; | 92 | import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; |
| 93 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | 93 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 94 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 94 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 95 | import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; | 95 | import { 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"; |
| 5 | import { connectionAtomRuntime } from "../connection/runtime"; | 5 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 6 | import { environmentSnapshotAtom } from "./shell"; | 6 | import { environmentSnapshotAtom } from "./shell"; |
| 7 | 7 | ||
| 8 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); | ||
| 9 | export const environmentProjects = createEnvironmentProjectAtoms({ | 8 | export const environmentProjects = createEnvironmentProjectAtoms({ |
| 10 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 9 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 11 | snapshotAtom: environmentSnapshotAtom, | 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,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 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; | ||
| 2 | import { | 3 | import { |
| 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"; |
| 8 | import { useCallback } from "react"; | 9 | import { useCallback } from "react"; |
| 9 | 10 | ||
| 10 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | 11 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 11 | import { waitForProject } from "~/state/entities"; | ||
| 12 | import { useEnvironments } from "~/state/environments"; | 12 | import { useEnvironments } from "~/state/environments"; |
| 13 | import { projectEnvironment } from "~/state/projects"; | 13 | import { projectEnvironment } from "~/state/projects"; |
| 14 | import { useAtomCommand } from "~/state/use-atom-command"; | 14 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 15 | import { useNewThreadHandler } from "./useHandleNewThread"; | 15 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 16 | 16 | ||
| 17 | function reportScratchFailure(error: unknown) { | 17 | function 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 | */ |
| 33 | export function useScratchProject() { | 33 | export 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(); |
| 37 | 37 | ||
| 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() { |
| 62 | 63 | ||
| 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 for | 75 | 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 | ); |
| 84 | 79 | ||
| 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"; |
| 7 | import { connectionAtomRuntime } from "../connection/runtime"; | 7 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | import { environmentSnapshotAtom } from "./shell"; | 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 | * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile | 18 | * 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-runtime | 19 | * 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 | }); |
| 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,7 +19,9 @@ A thread does not need a project. To start one without a project, click **or |
| 19 | start without a project** under a new thread's heading, pick **No project** from | 19 | start without a project** under a new thread's heading, pick **No project** from |
| 20 | the project menu in that heading or from **New thread in...** in the command | 20 | the project menu in that heading or from **New thread in...** in the command |
| 21 | palette, or press `mod+alt+n`. On mobile, pick **No project** from the project | 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 | Each thread without a project works in its own folder under `~/.t3/scratch` (the | 26 | Each 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 words | 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,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 | } |
| 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 | export type AddProjectRemoteSourceReadiness = Record< | 53 | export 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 @@ | ||
| 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,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 | import * as Crypto from "effect/Crypto"; | 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 | import { | 18 | import { |
| 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 | } |
| 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 | export function createProjectEnvironmentAtoms<R, E>( | 65 | export 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 | }), |