| author | |
| committer | |
| log | 758decaa565f021f85af756669b18bd9f80cacf5 |
| tree | 2e4d4a9bcf0e615ced31e20be6bbe2bad7b3a6cd |
| parent | 2d9066e089c241c3befd7a4632efe493fabd70d9 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 243 insertions(+), 56 deletions(-)
apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; | ||
| 6 | |||
| 7 | const ENVIRONMENT_A = EnvironmentId.make("environment-a"); | ||
| 8 | const ENVIRONMENT_B = EnvironmentId.make("environment-b"); | ||
| 9 | |||
| 10 | function environment(environmentId: EnvironmentId, connectionState: EnvironmentConnectionPhase) { | ||
| 11 | return { environmentId, connectionState }; | ||
| 12 | } | ||
| 13 | |||
| 14 | describe("resolveAddProjectEnvironment", () => { | ||
| 15 | it("does not redirect an explicit unavailable environment to another environment", () => { | ||
| 16 | expect( | ||
| 17 | resolveAddProjectEnvironment( | ||
| 18 | [environment(ENVIRONMENT_A, "offline"), environment(ENVIRONMENT_B, "connected")], | ||
| 19 | ENVIRONMENT_A, | ||
| 20 | ), | ||
| 21 | ).toBeNull(); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("resolves an explicit connected environment", () => { | ||
| 25 | expect( | ||
| 26 | resolveAddProjectEnvironment( | ||
| 27 | [environment(ENVIRONMENT_A, "connected"), environment(ENVIRONMENT_B, "connected")], | ||
| 28 | ENVIRONMENT_A, | ||
| 29 | )?.environmentId, | ||
| 30 | ).toBe(ENVIRONMENT_A); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("defaults to the first connected environment when no environment is requested", () => { | ||
| 34 | expect( | ||
| 35 | resolveAddProjectEnvironment( | ||
| 36 | [environment(ENVIRONMENT_A, "offline"), environment(ENVIRONMENT_B, "connected")], | ||
| 37 | null, | ||
| 38 | )?.environmentId, | ||
| 39 | ).toBe(ENVIRONMENT_B); | ||
| 40 | }); | ||
| 41 | }); | ||
apps/mobile/src/features/projects/AddProjectScreen.logic.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; | ||
| 2 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | export function resolveAddProjectEnvironment< | ||
| 6 | T extends { | ||
| 7 | readonly environmentId: EnvironmentId; | ||
| 8 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 9 | }, | ||
| 10 | >(environmentOptions: ReadonlyArray<T>, requestedEnvironmentId: EnvironmentId | null): T | null { | ||
| 11 | if (requestedEnvironmentId !== null) { | ||
| 12 | return ( | ||
| 13 | environmentOptions.find( | ||
| 14 | (environment) => | ||
| 15 | environment.environmentId === requestedEnvironmentId && | ||
| 16 | canCreateProjectInEnvironment(environment.connectionState), | ||
| 17 | ) ?? null | ||
| 18 | ); | ||
| 19 | } | ||
| 20 | |||
| 21 | return ( | ||
| 22 | environmentOptions.find((environment) => | ||
| 23 | canCreateProjectInEnvironment(environment.connectionState), | ||
| 24 | ) ?? null | ||
| 25 | ); | ||
| 26 | } | ||
apps/mobile/src/features/projects/AddProjectScreen.tsx+79-40| ... | @@ -4,12 +4,17 @@ import { | ... | @@ -4,12 +4,17 @@ import { |
| 4 | addProjectRemoteSourceProvider, | 4 | addProjectRemoteSourceProvider, |
| 5 | buildAddProjectRemoteSourceReadiness, | 5 | buildAddProjectRemoteSourceReadiness, |
| 6 | buildProjectCreateCommand, | 6 | buildProjectCreateCommand, |
| 7 | canCreateProjectInEnvironment, | ||
| 7 | findExistingAddProject, | 8 | findExistingAddProject, |
| 8 | getAddProjectInitialQuery, | 9 | getAddProjectInitialQuery, |
| 9 | resolveAddProjectPath, | 10 | resolveAddProjectPath, |
| 10 | sortAddProjectProviderSources, | 11 | sortAddProjectProviderSources, |
| 11 | type AddProjectRemoteSource, | 12 | type AddProjectRemoteSource, |
| 12 | } from "@t3tools/client-runtime/operations/projects"; | 13 | } from "@t3tools/client-runtime/operations/projects"; |
| 14 | import { | ||
| 15 | connectionStatusText, | ||
| 16 | type EnvironmentConnectionPhase, | ||
| 17 | } from "@t3tools/client-runtime/connection"; | ||
| 13 | import { | 18 | import { |
| 14 | canPreloadBrowsePath, | 19 | canPreloadBrowsePath, |
| 15 | createBrowseNavigationCoordinator, | 20 | createBrowseNavigationCoordinator, |
| ... | @@ -24,7 +29,7 @@ import { | ... | @@ -24,7 +29,7 @@ import { |
| 24 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; | 29 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 25 | import { StackActions, useNavigation } from "@react-navigation/native"; | 30 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 26 | import { SymbolView } from "../../components/AppSymbol"; | 31 | import { SymbolView } from "../../components/AppSymbol"; |
| 27 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; | 32 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 28 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | 33 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; |
| 29 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 34 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 30 | import * as Arr from "effect/Array"; | 35 | import * as Arr from "effect/Array"; |
| ... | @@ -46,15 +51,20 @@ import { uuidv4 } from "../../lib/uuid"; | ... | @@ -46,15 +51,20 @@ import { uuidv4 } from "../../lib/uuid"; |
| 46 | import { useAtomCommand } from "../../state/use-atom-command"; | 51 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 47 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; | 52 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 48 | import { | 53 | import { |
| 54 | useRemoteConnectionStatus, | ||
| 49 | useRemoteEnvironmentRuntime, | 55 | useRemoteEnvironmentRuntime, |
| 50 | useSavedRemoteConnections, | 56 | useSavedRemoteConnections, |
| 51 | } from "../../state/use-remote-environment-registry"; | 57 | } from "../../state/use-remote-environment-registry"; |
| 58 | import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; | ||
| 52 | 59 | ||
| 53 | interface EnvironmentOption { | 60 | interface EnvironmentOption { |
| 54 | readonly environmentId: EnvironmentId; | 61 | readonly environmentId: EnvironmentId; |
| 55 | readonly label: string; | 62 | readonly label: string; |
| 56 | readonly platform: string; | 63 | readonly platform: string; |
| 57 | readonly baseDirectory: string | null; | 64 | readonly baseDirectory: string | null; |
| 65 | readonly connectionState: EnvironmentConnectionPhase; | ||
| 66 | readonly connectionError: string | null; | ||
| 67 | readonly connectionErrorTraceId: string | null; | ||
| 58 | } | 68 | } |
| 59 | 69 | ||
| 60 | const environmentOptionOrder = Order.mapInput( | 70 | const environmentOptionOrder = Order.mapInput( |
| ... | @@ -227,10 +237,13 @@ function ProjectPathInput(props: { | ... | @@ -227,10 +237,13 @@ function ProjectPathInput(props: { |
| 227 | } | 237 | } |
| 228 | 238 | ||
| 229 | function useBrowsePathInput(environment: EnvironmentOption | null) { | 239 | function useBrowsePathInput(environment: EnvironmentOption | null) { |
| 240 | const environmentId = environment?.environmentId ?? null; | ||
| 241 | const environmentBaseDirectory = environment?.baseDirectory ?? null; | ||
| 230 | const [pathInput, commitPathInput] = useState(() => | 242 | const [pathInput, commitPathInput] = useState(() => |
| 231 | getAddProjectInitialQuery(environment?.baseDirectory), | 243 | getAddProjectInitialQuery(environmentBaseDirectory), |
| 232 | ); | 244 | ); |
| 233 | const environmentRuntime = useRemoteEnvironmentRuntime(environment?.environmentId ?? null); | 245 | const previousEnvironmentIdRef = useRef(environmentId); |
| 246 | const environmentRuntime = useRemoteEnvironmentRuntime(environmentId); | ||
| 234 | const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { | 247 | const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { |
| 235 | reportFailure: false, | 248 | reportFailure: false, |
| 236 | reportDefect: false, | 249 | reportDefect: false, |
| ... | @@ -268,10 +281,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { | ... | @@ -268,10 +281,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { |
| 268 | ); | 281 | ); |
| 269 | 282 | ||
| 270 | useEffect(() => { | 283 | useEffect(() => { |
| 271 | if (environment) { | 284 | if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { |
| 272 | setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); | 285 | previousEnvironmentIdRef.current = environmentId; |
| 286 | setPathInput(getAddProjectInitialQuery(environmentBaseDirectory)); | ||
| 273 | } | 287 | } |
| 274 | }, [environment, setPathInput]); | 288 | }, [environmentBaseDirectory, environmentId, setPathInput]); |
| 275 | 289 | ||
| 276 | useEffect( | 290 | useEffect( |
| 277 | () => () => { | 291 | () => () => { |
| ... | @@ -286,19 +300,27 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { | ... | @@ -286,19 +300,27 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { |
| 286 | function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { | 300 | function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { |
| 287 | const serverConfigByEnvironmentId = useServerConfigs(); | 301 | const serverConfigByEnvironmentId = useServerConfigs(); |
| 288 | const { savedConnectionsById } = useSavedRemoteConnections(); | 302 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 303 | const { connectedEnvironments } = useRemoteConnectionStatus(); | ||
| 289 | 304 | ||
| 290 | return useMemo<ReadonlyArray<EnvironmentOption>>(() => { | 305 | return useMemo<ReadonlyArray<EnvironmentOption>>(() => { |
| 306 | const runtimeByEnvironmentId = new Map( | ||
| 307 | connectedEnvironments.map((environment) => [environment.environmentId, environment] as const), | ||
| 308 | ); | ||
| 291 | const options = Object.values(savedConnectionsById).map((connection) => { | 309 | const options = Object.values(savedConnectionsById).map((connection) => { |
| 292 | const config = serverConfigByEnvironmentId.get(connection.environmentId); | 310 | const config = serverConfigByEnvironmentId.get(connection.environmentId); |
| 311 | const runtime = runtimeByEnvironmentId.get(connection.environmentId); | ||
| 293 | return { | 312 | return { |
| 294 | environmentId: connection.environmentId, | 313 | environmentId: connection.environmentId, |
| 295 | label: connection.environmentLabel, | 314 | label: connection.environmentLabel, |
| 296 | platform: platformFromOs(config?.environment.platform.os ?? null), | 315 | platform: platformFromOs(config?.environment.platform.os ?? null), |
| 297 | baseDirectory: config?.settings.addProjectBaseDirectory ?? null, | 316 | baseDirectory: config?.settings.addProjectBaseDirectory ?? null, |
| 317 | connectionState: runtime?.connectionState ?? "available", | ||
| 318 | connectionError: runtime?.connectionError ?? null, | ||
| 319 | connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, | ||
| 298 | }; | 320 | }; |
| 299 | }); | 321 | }); |
| 300 | return Arr.sort(options, environmentOptionOrder); | 322 | return Arr.sort(options, environmentOptionOrder); |
| 301 | }, [savedConnectionsById, serverConfigByEnvironmentId]); | 323 | }, [connectedEnvironments, savedConnectionsById, serverConfigByEnvironmentId]); |
| 302 | } | 324 | } |
| 303 | 325 | ||
| 304 | function useSelectedEnvironment(): { | 326 | function useSelectedEnvironment(): { |
| ... | @@ -309,8 +331,14 @@ function useSelectedEnvironment(): { | ... | @@ -309,8 +331,14 @@ function useSelectedEnvironment(): { |
| 309 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); | 331 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); |
| 310 | const environmentOptions = useEnvironmentOptions(); | 332 | const environmentOptions = useEnvironmentOptions(); |
| 311 | const selectedEnvironment = | 333 | const selectedEnvironment = |
| 312 | environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? | 334 | environmentOptions.find( |
| 313 | environmentOptions[0] ?? | 335 | (environment) => |
| 336 | environment.environmentId === selectedEnvironmentId && | ||
| 337 | canCreateProjectInEnvironment(environment.connectionState), | ||
| 338 | ) ?? | ||
| 339 | environmentOptions.find((environment) => | ||
| 340 | canCreateProjectInEnvironment(environment.connectionState), | ||
| 341 | ) ?? | ||
| 314 | null; | 342 | null; |
| 315 | 343 | ||
| 316 | return { | 344 | return { |
| ... | @@ -325,9 +353,9 @@ function EmptyEnvironmentState() { | ... | @@ -325,9 +353,9 @@ function EmptyEnvironmentState() { |
| 325 | 353 | ||
| 326 | return ( | 354 | return ( |
| 327 | <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8"> | 355 | <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8"> |
| 328 | <Text className="text-center text-lg font-t3-bold">No environments connected</Text> | 356 | <Text className="text-center text-lg font-t3-bold">Environment unavailable</Text> |
| 329 | <Text className="text-center text-sm leading-normal text-foreground-muted"> | 357 | <Text className="text-center text-sm leading-normal text-foreground-muted"> |
| 330 | Add an environment before adding a project. | 358 | Start or reconnect an environment before adding a project. |
| 331 | </Text> | 359 | </Text> |
| 332 | <Pressable | 360 | <Pressable |
| 333 | onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))} | 361 | onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))} |
| ... | @@ -407,17 +435,25 @@ export function AddProjectSourceScreen() { | ... | @@ -407,17 +435,25 @@ export function AddProjectSourceScreen() { |
| 407 | 435 | ||
| 408 | return ( | 436 | return ( |
| 409 | <AddProjectShell> | 437 | <AddProjectShell> |
| 410 | {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null} | 438 | {selectedEnvironment === null ? <EmptyEnvironmentState /> : null} |
| 411 | 439 | ||
| 412 | {environmentOptions.length > 1 ? ( | 440 | {environmentOptions.length > 1 ? ( |
| 413 | <> | 441 | <> |
| 414 | <SectionTitle>Connected environments</SectionTitle> | 442 | <SectionTitle>Environments</SectionTitle> |
| 415 | <ListSection> | 443 | <ListSection> |
| 416 | {environmentOptions.map((environment, index) => ( | 444 | {environmentOptions.map((environment, index) => ( |
| 417 | <ListRow | 445 | <ListRow |
| 418 | key={environment.environmentId} | 446 | key={environment.environmentId} |
| 419 | title={environment.label} | 447 | title={environment.label} |
| 420 | subtitle={environment.environmentId} | 448 | subtitle={ |
| 449 | canCreateProjectInEnvironment(environment.connectionState) | ||
| 450 | ? environment.environmentId | ||
| 451 | : connectionStatusText({ | ||
| 452 | phase: environment.connectionState, | ||
| 453 | error: environment.connectionError, | ||
| 454 | traceId: environment.connectionErrorTraceId, | ||
| 455 | }) | ||
| 456 | } | ||
| 421 | icon={ | 457 | icon={ |
| 422 | <SymbolView | 458 | <SymbolView |
| 423 | name="server.rack" | 459 | name="server.rack" |
| ... | @@ -427,6 +463,7 @@ export function AddProjectSourceScreen() { | ... | @@ -427,6 +463,7 @@ export function AddProjectSourceScreen() { |
| 427 | /> | 463 | /> |
| 428 | } | 464 | } |
| 429 | selected={environment.environmentId === selectedEnvironment?.environmentId} | 465 | selected={environment.environmentId === selectedEnvironment?.environmentId} |
| 466 | disabled={!canCreateProjectInEnvironment(environment.connectionState)} | ||
| 430 | isFirst={index === 0} | 467 | isFirst={index === 0} |
| 431 | right={ | 468 | right={ |
| 432 | environment.environmentId === selectedEnvironment?.environmentId ? ( | 469 | environment.environmentId === selectedEnvironment?.environmentId ? ( |
| ... | @@ -500,7 +537,7 @@ function useCreateProject(environment: EnvironmentOption | null) { | ... | @@ -500,7 +537,7 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 500 | 537 | ||
| 501 | return useCallback( | 538 | return useCallback( |
| 502 | async (workspaceRoot: string) => { | 539 | async (workspaceRoot: string) => { |
| 503 | if (!environment) return; | 540 | if (!environment || !canCreateProjectInEnvironment(environment.connectionState)) return; |
| 504 | 541 | ||
| 505 | const existing = findExistingAddProject({ | 542 | const existing = findExistingAddProject({ |
| 506 | projects, | 543 | projects, |
| ... | @@ -551,11 +588,7 @@ function useEnvironmentFromParam( | ... | @@ -551,11 +588,7 @@ function useEnvironmentFromParam( |
| 551 | ): EnvironmentOption | null { | 588 | ): EnvironmentOption | null { |
| 552 | const environmentOptions = useEnvironmentOptions(); | 589 | const environmentOptions = useEnvironmentOptions(); |
| 553 | const environmentId = stringParam(environmentIdParam) as EnvironmentId | null; | 590 | const environmentId = stringParam(environmentIdParam) as EnvironmentId | null; |
| 554 | return ( | 591 | return resolveAddProjectEnvironment(environmentOptions, environmentId); |
| 555 | environmentOptions.find((environment) => environment.environmentId === environmentId) ?? | ||
| 556 | environmentOptions[0] ?? | ||
| 557 | null | ||
| 558 | ); | ||
| 559 | } | 592 | } |
| 560 | 593 | ||
| 561 | export function AddProjectRepositoryScreen(props: { | 594 | export function AddProjectRepositoryScreen(props: { |
| ... | @@ -619,26 +652,32 @@ export function AddProjectRepositoryScreen(props: { | ... | @@ -619,26 +652,32 @@ export function AddProjectRepositoryScreen(props: { |
| 619 | return ( | 652 | return ( |
| 620 | <AddProjectShell> | 653 | <AddProjectShell> |
| 621 | {error ? <ErrorBanner message={error} /> : null} | 654 | {error ? <ErrorBanner message={error} /> : null} |
| 622 | <TextInput | 655 | {environment ? ( |
| 623 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug" | 656 | <> |
| 624 | value={repositoryInput} | 657 | <TextInput |
| 625 | onChangeText={setRepositoryInput} | 658 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug" |
| 626 | autoCapitalize="none" | 659 | value={repositoryInput} |
| 627 | autoCorrect={false} | 660 | onChangeText={setRepositoryInput} |
| 628 | placeholder={ | 661 | autoCapitalize="none" |
| 629 | source === "url" | 662 | autoCorrect={false} |
| 630 | ? "https://github.com/org/repo.git" | 663 | placeholder={ |
| 631 | : addProjectRemoteSourcePathHint(source) | 664 | source === "url" |
| 632 | } | 665 | ? "https://github.com/org/repo.git" |
| 633 | returnKeyType="next" | 666 | : addProjectRemoteSourcePathHint(source) |
| 634 | onSubmitEditing={() => void lookupRepository()} | 667 | } |
| 635 | /> | 668 | returnKeyType="next" |
| 636 | <PrimaryActionButton | 669 | onSubmitEditing={() => void lookupRepository()} |
| 637 | label={source === "url" ? "Continue" : "Lookup repository"} | 670 | /> |
| 638 | disabled={isSubmitting || repositoryInput.trim().length === 0} | 671 | <PrimaryActionButton |
| 639 | onPress={() => void lookupRepository()} | 672 | label={source === "url" ? "Continue" : "Lookup repository"} |
| 640 | loading={isSubmitting} | 673 | disabled={isSubmitting || repositoryInput.trim().length === 0} |
| 641 | /> | 674 | onPress={() => void lookupRepository()} |
| 675 | loading={isSubmitting} | ||
| 676 | /> | ||
| 677 | </> | ||
| 678 | ) : ( | ||
| 679 | <EmptyEnvironmentState /> | ||
| 680 | )} | ||
| 642 | </AddProjectShell> | 681 | </AddProjectShell> |
| 643 | ); | 682 | ); |
| 644 | } | 683 | } |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+18-12| ... | @@ -203,13 +203,17 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -203,13 +203,17 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 203 | title={screenTitle} | 203 | title={screenTitle} |
| 204 | subtitle={incomingShareSubtitle} | 204 | subtitle={incomingShareSubtitle} |
| 205 | onBack={layout.usesSplitView ? () => navigation.goBack() : undefined} | 205 | onBack={layout.usesSplitView ? () => navigation.goBack() : undefined} |
| 206 | actions={[ | 206 | actions={ |
| 207 | { | 207 | catalogState.hasReadyEnvironment |
| 208 | accessibilityLabel: "Add project", | 208 | ? [ |
| 209 | icon: "plus", | 209 | { |
| 210 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), | 210 | accessibilityLabel: "Add project", |
| 211 | }, | 211 | icon: "plus", |
| 212 | ]} | 212 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), |
| 213 | }, | ||
| 214 | ] | ||
| 215 | : [] | ||
| 216 | } | ||
| 213 | /> | 217 | /> |
| 214 | </> | 218 | </> |
| 215 | ) : ( | 219 | ) : ( |
| ... | @@ -229,11 +233,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -229,11 +233,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 229 | separateBackground | 233 | separateBackground |
| 230 | /> | 234 | /> |
| 231 | ) : null} | 235 | ) : null} |
| 232 | <NativeHeaderToolbar.Button | 236 | {catalogState.hasReadyEnvironment ? ( |
| 233 | icon="plus" | 237 | <NativeHeaderToolbar.Button |
| 234 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | 238 | icon="plus" |
| 235 | separateBackground | 239 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} |
| 236 | /> | 240 | separateBackground |
| 241 | /> | ||
| 242 | ) : null} | ||
| 237 | </NativeHeaderToolbar> | 243 | </NativeHeaderToolbar> |
| 238 | </> | 244 | </> |
| 239 | )} | 245 | )} |
apps/web/src/components/CommandPalette.tsx+62-4| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 4 | import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; | ||
| 5 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; | ||
| 4 | import { | 6 | import { |
| 5 | canPreloadBrowsePath, | 7 | canPreloadBrowsePath, |
| 6 | createBrowseNavigationCoordinator, | 8 | createBrowseNavigationCoordinator, |
| ... | @@ -163,6 +165,8 @@ interface AddProjectEnvironmentOption { | ... | @@ -163,6 +165,8 @@ interface AddProjectEnvironmentOption { |
| 163 | readonly environmentId: EnvironmentId; | 165 | readonly environmentId: EnvironmentId; |
| 164 | readonly label: string; | 166 | readonly label: string; |
| 165 | readonly isPrimary: boolean; | 167 | readonly isPrimary: boolean; |
| 168 | readonly isConnected: boolean; | ||
| 169 | readonly status: string; | ||
| 166 | } | 170 | } |
| 167 | 171 | ||
| 168 | type AddProjectRemoteProviderKind = Extract< | 172 | type AddProjectRemoteProviderKind = Extract< |
| ... | @@ -620,6 +624,8 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -620,6 +624,8 @@ function OpenCommandPaletteDialog(props: { |
| 620 | runtimeLabel: environment.label, | 624 | runtimeLabel: environment.label, |
| 621 | }), | 625 | }), |
| 622 | isPrimary, | 626 | isPrimary, |
| 627 | isConnected: canCreateProjectInEnvironment(environment.connection.phase), | ||
| 628 | status: connectionStatusText(environment.connection), | ||
| 623 | }; | 629 | }; |
| 624 | }); | 630 | }); |
| 625 | 631 | ||
| ... | @@ -632,10 +638,14 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -632,10 +638,14 @@ function OpenCommandPaletteDialog(props: { |
| 632 | 638 | ||
| 633 | return options; | 639 | return options; |
| 634 | }, [environments]); | 640 | }, [environments]); |
| 635 | const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; | 641 | const defaultAddProjectEnvironmentId = |
| 642 | addProjectEnvironmentOptions.find((option) => option.isConnected)?.environmentId ?? null; | ||
| 636 | const wslAddProjectEnvironmentOption = useMemo( | 643 | const wslAddProjectEnvironmentOption = useMemo( |
| 637 | () => | 644 | () => |
| 638 | addProjectEnvironmentOptions.find((option) => { | 645 | addProjectEnvironmentOptions.find((option) => { |
| 646 | if (!option.isConnected) { | ||
| 647 | return false; | ||
| 648 | } | ||
| 639 | const environment = environments.find( | 649 | const environment = environments.find( |
| 640 | (candidate) => candidate.environmentId === option.environmentId, | 650 | (candidate) => candidate.environmentId === option.environmentId, |
| 641 | ); | 651 | ); |
| ... | @@ -1108,6 +1118,19 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1108,6 +1118,19 @@ function OpenCommandPaletteDialog(props: { |
| 1108 | 1118 | ||
| 1109 | const startAddProjectSourceSelection = useCallback( | 1119 | const startAddProjectSourceSelection = useCallback( |
| 1110 | (environmentId: EnvironmentId): void => { | 1120 | (environmentId: EnvironmentId): void => { |
| 1121 | const environment = environments.find( | ||
| 1122 | (candidate) => candidate.environmentId === environmentId, | ||
| 1123 | ); | ||
| 1124 | if (!canCreateProjectInEnvironment(environment?.connection.phase)) { | ||
| 1125 | toastManager.add( | ||
| 1126 | stackedThreadToast({ | ||
| 1127 | type: "error", | ||
| 1128 | title: "Environment unavailable", | ||
| 1129 | description: `${environment?.label ?? "The selected environment"} is not connected.`, | ||
| 1130 | }), | ||
| 1131 | ); | ||
| 1132 | return; | ||
| 1133 | } | ||
| 1111 | setAddProjectEnvironmentId(environmentId); | 1134 | setAddProjectEnvironmentId(environmentId); |
| 1112 | setAddProjectCloneFlow(null); | 1135 | setAddProjectCloneFlow(null); |
| 1113 | pushPaletteView({ | 1136 | pushPaletteView({ |
| ... | @@ -1123,6 +1146,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1123,6 +1146,7 @@ function OpenCommandPaletteDialog(props: { |
| 1123 | [ | 1146 | [ |
| 1124 | browseEnvironmentId, | 1147 | browseEnvironmentId, |
| 1125 | buildAddProjectSourceGroups, | 1148 | buildAddProjectSourceGroups, |
| 1149 | environments, | ||
| 1126 | pushPaletteView, | 1150 | pushPaletteView, |
| 1127 | sourceControlDiscovery.data, | 1151 | sourceControlDiscovery.data, |
| 1128 | ], | 1152 | ], |
| ... | @@ -1134,7 +1158,12 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1134,7 +1158,12 @@ function OpenCommandPaletteDialog(props: { |
| 1134 | value: `action:add-project:environment:${option.environmentId}`, | 1158 | value: `action:add-project:environment:${option.environmentId}`, |
| 1135 | searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], | 1159 | searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], |
| 1136 | title: option.label, | 1160 | title: option.label, |
| 1137 | description: option.isPrimary ? "This device" : option.environmentId, | 1161 | description: option.isConnected |
| 1162 | ? option.isPrimary | ||
| 1163 | ? "This device" | ||
| 1164 | : option.environmentId | ||
| 1165 | : option.status, | ||
| 1166 | disabled: !option.isConnected, | ||
| 1138 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | 1167 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, |
| 1139 | keepOpen: true, | 1168 | keepOpen: true, |
| 1140 | run: async () => { | 1169 | run: async () => { |
| ... | @@ -1155,7 +1184,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1155,7 +1184,7 @@ function OpenCommandPaletteDialog(props: { |
| 1155 | ); | 1184 | ); |
| 1156 | 1185 | ||
| 1157 | const openAddProjectFlow = useCallback(() => { | 1186 | const openAddProjectFlow = useCallback(() => { |
| 1158 | if (addProjectEnvironmentOptions.length > 1) { | 1187 | if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) { |
| 1159 | pushPaletteView({ | 1188 | pushPaletteView({ |
| 1160 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | 1189 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, |
| 1161 | groups: addProjectEnvironmentGroups, | 1190 | groups: addProjectEnvironmentGroups, |
| ... | @@ -1294,6 +1323,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1294,6 +1323,7 @@ function OpenCommandPaletteDialog(props: { |
| 1294 | "environment", | 1323 | "environment", |
| 1295 | ], | 1324 | ], |
| 1296 | title: "Add project", | 1325 | title: "Add project", |
| 1326 | disabled: defaultAddProjectEnvironmentId === null, | ||
| 1297 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | 1327 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, |
| 1298 | keepOpen: true, | 1328 | keepOpen: true, |
| 1299 | run: async () => { | 1329 | run: async () => { |
| ... | @@ -1355,6 +1385,19 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1355,6 +1385,19 @@ function OpenCommandPaletteDialog(props: { |
| 1355 | readonly platform: string; | 1385 | readonly platform: string; |
| 1356 | readonly currentProjectCwd: string | null; | 1386 | readonly currentProjectCwd: string | null; |
| 1357 | }) => { | 1387 | }) => { |
| 1388 | const environment = environments.find( | ||
| 1389 | (candidate) => candidate.environmentId === input.environmentId, | ||
| 1390 | ); | ||
| 1391 | if (!canCreateProjectInEnvironment(environment?.connection.phase)) { | ||
| 1392 | toastManager.add( | ||
| 1393 | stackedThreadToast({ | ||
| 1394 | type: "error", | ||
| 1395 | title: "Environment unavailable", | ||
| 1396 | description: `${environment?.label ?? "The selected environment"} is not connected.`, | ||
| 1397 | }), | ||
| 1398 | ); | ||
| 1399 | return; | ||
| 1400 | } | ||
| 1358 | const rawCwd = input.rawCwd; | 1401 | const rawCwd = input.rawCwd; |
| 1359 | 1402 | ||
| 1360 | if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) { | 1403 | if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) { |
| ... | @@ -1507,6 +1550,16 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1507,6 +1550,16 @@ function OpenCommandPaletteDialog(props: { |
| 1507 | if (!addProjectCloneFlow) { | 1550 | if (!addProjectCloneFlow) { |
| 1508 | return; | 1551 | return; |
| 1509 | } | 1552 | } |
| 1553 | if (!canCreateProjectInEnvironment(browseEnvironment?.connection.phase)) { | ||
| 1554 | toastManager.add( | ||
| 1555 | stackedThreadToast({ | ||
| 1556 | type: "error", | ||
| 1557 | title: "Environment unavailable", | ||
| 1558 | description: `${browseEnvironment?.label ?? "The selected environment"} is not connected.`, | ||
| 1559 | }), | ||
| 1560 | ); | ||
| 1561 | return; | ||
| 1562 | } | ||
| 1510 | 1563 | ||
| 1511 | if (addProjectCloneFlow.step === "repository") { | 1564 | if (addProjectCloneFlow.step === "repository") { |
| 1512 | const rawRepository = query.trim(); | 1565 | const rawRepository = query.trim(); |
| ... | @@ -1711,7 +1764,10 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1711,7 +1764,10 @@ function OpenCommandPaletteDialog(props: { |
| 1711 | getCommandPaletteInputPlaceholder(paletteMode); | 1764 | getCommandPaletteInputPlaceholder(paletteMode); |
| 1712 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; | 1765 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; |
| 1713 | const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; | 1766 | const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; |
| 1714 | const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; | 1767 | const canSubmitBrowsePath = |
| 1768 | isBrowsing && | ||
| 1769 | !relativePathNeedsActiveProject && | ||
| 1770 | canCreateProjectInEnvironment(browseEnvironment?.connection.phase); | ||
| 1715 | const willCreateProjectPath = | 1771 | const willCreateProjectPath = |
| 1716 | canSubmitBrowsePath && | 1772 | canSubmitBrowsePath && |
| 1717 | !isBrowsePending && | 1773 | !isBrowsePending && |
| ... | @@ -1738,6 +1794,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1738,6 +1794,7 @@ function OpenCommandPaletteDialog(props: { |
| 1738 | const canSubmitRemoteProjectFlow = | 1794 | const canSubmitRemoteProjectFlow = |
| 1739 | addProjectCloneFlow?.step === "repository" && | 1795 | addProjectCloneFlow?.step === "repository" && |
| 1740 | query.trim().length > 0 && | 1796 | query.trim().length > 0 && |
| 1797 | canCreateProjectInEnvironment(browseEnvironment?.connection.phase) && | ||
| 1741 | !isRemoteProjectPending; | 1798 | !isRemoteProjectPending; |
| 1742 | const fileManagerName = getLocalFileManagerName(navigator.platform); | 1799 | const fileManagerName = getLocalFileManagerName(navigator.platform); |
| 1743 | const canOpenProjectFromFileManager = | 1800 | const canOpenProjectFromFileManager = |
| ... | @@ -2063,6 +2120,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2063,6 +2120,7 @@ function OpenCommandPaletteDialog(props: { |
| 2063 | )} | 2120 | )} |
| 2064 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} | 2121 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} |
| 2065 | disabled={ | 2122 | disabled={ |
| 2123 | !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || | ||
| 2066 | relativePathNeedsActiveProject || | 2124 | relativePathNeedsActiveProject || |
| 2067 | (isCloneDestinationStep && isRemoteProjectPending) | 2125 | (isCloneDestinationStep && isRemoteProjectPending) |
| 2068 | } | 2126 | } |
packages/client-runtime/src/operations/projects.test.ts+10| ... | @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; | ... | @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; |
| 10 | import { | 10 | import { |
| 11 | buildAddProjectRemoteSourceReadiness, | 11 | buildAddProjectRemoteSourceReadiness, |
| 12 | buildProjectCreateCommand, | 12 | buildProjectCreateCommand, |
| 13 | canCreateProjectInEnvironment, | ||
| 13 | findExistingAddProject, | 14 | findExistingAddProject, |
| 14 | getAddProjectInitialQuery, | 15 | getAddProjectInitialQuery, |
| 15 | resolveAddProjectPath, | 16 | resolveAddProjectPath, |
| ... | @@ -18,6 +19,15 @@ import { | ... | @@ -18,6 +19,15 @@ import { |
| 18 | import type { EnvironmentProject } from "../state/models.ts"; | 19 | import type { EnvironmentProject } from "../state/models.ts"; |
| 19 | 20 | ||
| 20 | describe("add project shared logic", () => { | 21 | describe("add project shared logic", () => { |
| 22 | it("only allows project creation in connected environments", () => { | ||
| 23 | expect(canCreateProjectInEnvironment("connected")).toBe(true); | ||
| 24 | expect(canCreateProjectInEnvironment("available")).toBe(false); | ||
| 25 | expect(canCreateProjectInEnvironment("offline")).toBe(false); | ||
| 26 | expect(canCreateProjectInEnvironment("connecting")).toBe(false); | ||
| 27 | expect(canCreateProjectInEnvironment("reconnecting")).toBe(false); | ||
| 28 | expect(canCreateProjectInEnvironment("error")).toBe(false); | ||
| 29 | }); | ||
| 30 | |||
| 21 | it("resolves initial browse paths from settings", () => { | 31 | it("resolves initial browse paths from settings", () => { |
| 22 | expect(getAddProjectInitialQuery("")).toBe("~/"); | 32 | expect(getAddProjectInitialQuery("")).toBe("~/"); |
| 23 | expect(getAddProjectInitialQuery("/work")).toBe("/work/"); | 33 | expect(getAddProjectInitialQuery("/work")).toBe("/work/"); |
packages/client-runtime/src/operations/projects.ts+7| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; | ||
| 1 | import type { | 2 | import type { |
| 2 | CommandId, | 3 | CommandId, |
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| ... | @@ -27,6 +28,12 @@ export type AddProjectRemoteProviderKind = Extract< | ... | @@ -27,6 +28,12 @@ export type AddProjectRemoteProviderKind = Extract< |
| 27 | >; | 28 | >; |
| 28 | export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | 29 | export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; |
| 29 | 30 | ||
| 31 | export function canCreateProjectInEnvironment( | ||
| 32 | connectionPhase: EnvironmentConnectionPhase | null | undefined, | ||
| 33 | ): boolean { | ||
| 34 | return connectionPhase === "connected"; | ||
| 35 | } | ||
| 36 | |||
| 30 | export type AddProjectRemoteSourceReadiness = Record< | 37 | export type AddProjectRemoteSourceReadiness = Record< |
| 31 | AddProjectRemoteSource, | 38 | AddProjectRemoteSource, |
| 32 | { readonly ready: boolean; readonly hint: string | null } | 39 | { readonly ready: boolean; readonly hint: string | null } |