| 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 | 4 | addProjectRemoteSourceProvider, |
| 5 | 5 | buildAddProjectRemoteSourceReadiness, |
| 6 | 6 | buildProjectCreateCommand, |
| 7 | canCreateProjectInEnvironment, | |
| 7 | 8 | findExistingAddProject, |
| 8 | 9 | getAddProjectInitialQuery, |
| 9 | 10 | resolveAddProjectPath, |
| 10 | 11 | sortAddProjectProviderSources, |
| 11 | 12 | type AddProjectRemoteSource, |
| 12 | 13 | } from "@t3tools/client-runtime/operations/projects"; |
| 14 | import { | |
| 15 | connectionStatusText, | |
| 16 | type EnvironmentConnectionPhase, | |
| 17 | } from "@t3tools/client-runtime/connection"; | |
| 13 | 18 | import { |
| 14 | 19 | canPreloadBrowsePath, |
| 15 | 20 | createBrowseNavigationCoordinator, |
| ... | ... | @@ -24,7 +29,7 @@ import { |
| 24 | 29 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 25 | 30 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 26 | 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 | 33 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; |
| 29 | 34 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 30 | 35 | import * as Arr from "effect/Array"; |
| ... | ... | @@ -46,15 +51,20 @@ import { uuidv4 } from "../../lib/uuid"; |
| 46 | 51 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 47 | 52 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 48 | 53 | import { |
| 54 | useRemoteConnectionStatus, | |
| 49 | 55 | useRemoteEnvironmentRuntime, |
| 50 | 56 | useSavedRemoteConnections, |
| 51 | 57 | } from "../../state/use-remote-environment-registry"; |
| 58 | import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; | |
| 52 | 59 | |
| 53 | 60 | interface EnvironmentOption { |
| 54 | 61 | readonly environmentId: EnvironmentId; |
| 55 | 62 | readonly label: string; |
| 56 | 63 | readonly platform: string; |
| 57 | 64 | readonly baseDirectory: string | null; |
| 65 | readonly connectionState: EnvironmentConnectionPhase; | |
| 66 | readonly connectionError: string | null; | |
| 67 | readonly connectionErrorTraceId: string | null; | |
| 58 | 68 | } |
| 59 | 69 | |
| 60 | 70 | const environmentOptionOrder = Order.mapInput( |
| ... | ... | @@ -227,10 +237,13 @@ function ProjectPathInput(props: { |
| 227 | 237 | } |
| 228 | 238 | |
| 229 | 239 | function useBrowsePathInput(environment: EnvironmentOption | null) { |
| 240 | const environmentId = environment?.environmentId ?? null; | |
| 241 | const environmentBaseDirectory = environment?.baseDirectory ?? null; | |
| 230 | 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 | 247 | const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { |
| 235 | 248 | reportFailure: false, |
| 236 | 249 | reportDefect: false, |
| ... | ... | @@ -268,10 +281,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { |
| 268 | 281 | ); |
| 269 | 282 | |
| 270 | 283 | useEffect(() => { |
| 271 | if (environment) { | |
| 272 | setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); | |
| 284 | if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { | |
| 285 | previousEnvironmentIdRef.current = environmentId; | |
| 286 | setPathInput(getAddProjectInitialQuery(environmentBaseDirectory)); | |
| 273 | 287 | } |
| 274 | }, [environment, setPathInput]); | |
| 288 | }, [environmentBaseDirectory, environmentId, setPathInput]); | |
| 275 | 289 | |
| 276 | 290 | useEffect( |
| 277 | 291 | () => () => { |
| ... | ... | @@ -286,19 +300,27 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { |
| 286 | 300 | function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { |
| 287 | 301 | const serverConfigByEnvironmentId = useServerConfigs(); |
| 288 | 302 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 303 | const { connectedEnvironments } = useRemoteConnectionStatus(); | |
| 289 | 304 | |
| 290 | 305 | return useMemo<ReadonlyArray<EnvironmentOption>>(() => { |
| 306 | const runtimeByEnvironmentId = new Map( | |
| 307 | connectedEnvironments.map((environment) => [environment.environmentId, environment] as const), | |
| 308 | ); | |
| 291 | 309 | const options = Object.values(savedConnectionsById).map((connection) => { |
| 292 | 310 | const config = serverConfigByEnvironmentId.get(connection.environmentId); |
| 311 | const runtime = runtimeByEnvironmentId.get(connection.environmentId); | |
| 293 | 312 | return { |
| 294 | 313 | environmentId: connection.environmentId, |
| 295 | 314 | label: connection.environmentLabel, |
| 296 | 315 | platform: platformFromOs(config?.environment.platform.os ?? null), |
| 297 | 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 | 322 | return Arr.sort(options, environmentOptionOrder); |
| 301 | }, [savedConnectionsById, serverConfigByEnvironmentId]); | |
| 323 | }, [connectedEnvironments, savedConnectionsById, serverConfigByEnvironmentId]); | |
| 302 | 324 | } |
| 303 | 325 | |
| 304 | 326 | function useSelectedEnvironment(): { |
| ... | ... | @@ -309,8 +331,14 @@ function useSelectedEnvironment(): { |
| 309 | 331 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); |
| 310 | 332 | const environmentOptions = useEnvironmentOptions(); |
| 311 | 333 | const selectedEnvironment = |
| 312 | environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? | |
| 313 | environmentOptions[0] ?? | |
| 334 | environmentOptions.find( | |
| 335 | (environment) => | |
| 336 | environment.environmentId === selectedEnvironmentId && | |
| 337 | canCreateProjectInEnvironment(environment.connectionState), | |
| 338 | ) ?? | |
| 339 | environmentOptions.find((environment) => | |
| 340 | canCreateProjectInEnvironment(environment.connectionState), | |
| 341 | ) ?? | |
| 314 | 342 | null; |
| 315 | 343 | |
| 316 | 344 | return { |
| ... | ... | @@ -325,9 +353,9 @@ function EmptyEnvironmentState() { |
| 325 | 353 | |
| 326 | 354 | return ( |
| 327 | 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 | 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 | 359 | </Text> |
| 332 | 360 | <Pressable |
| 333 | 361 | onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))} |
| ... | ... | @@ -407,17 +435,25 @@ export function AddProjectSourceScreen() { |
| 407 | 435 | |
| 408 | 436 | return ( |
| 409 | 437 | <AddProjectShell> |
| 410 | {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null} | |
| 438 | {selectedEnvironment === null ? <EmptyEnvironmentState /> : null} | |
| 411 | 439 | |
| 412 | 440 | {environmentOptions.length > 1 ? ( |
| 413 | 441 | <> |
| 414 | <SectionTitle>Connected environments</SectionTitle> | |
| 442 | <SectionTitle>Environments</SectionTitle> | |
| 415 | 443 | <ListSection> |
| 416 | 444 | {environmentOptions.map((environment, index) => ( |
| 417 | 445 | <ListRow |
| 418 | 446 | key={environment.environmentId} |
| 419 | 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 | 457 | icon={ |
| 422 | 458 | <SymbolView |
| 423 | 459 | name="server.rack" |
| ... | ... | @@ -427,6 +463,7 @@ export function AddProjectSourceScreen() { |
| 427 | 463 | /> |
| 428 | 464 | } |
| 429 | 465 | selected={environment.environmentId === selectedEnvironment?.environmentId} |
| 466 | disabled={!canCreateProjectInEnvironment(environment.connectionState)} | |
| 430 | 467 | isFirst={index === 0} |
| 431 | 468 | right={ |
| 432 | 469 | environment.environmentId === selectedEnvironment?.environmentId ? ( |
| ... | ... | @@ -500,7 +537,7 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 500 | 537 | |
| 501 | 538 | return useCallback( |
| 502 | 539 | async (workspaceRoot: string) => { |
| 503 | if (!environment) return; | |
| 540 | if (!environment || !canCreateProjectInEnvironment(environment.connectionState)) return; | |
| 504 | 541 | |
| 505 | 542 | const existing = findExistingAddProject({ |
| 506 | 543 | projects, |
| ... | ... | @@ -551,11 +588,7 @@ function useEnvironmentFromParam( |
| 551 | 588 | ): EnvironmentOption | null { |
| 552 | 589 | const environmentOptions = useEnvironmentOptions(); |
| 553 | 590 | const environmentId = stringParam(environmentIdParam) as EnvironmentId | null; |
| 554 | return ( | |
| 555 | environmentOptions.find((environment) => environment.environmentId === environmentId) ?? | |
| 556 | environmentOptions[0] ?? | |
| 557 | null | |
| 558 | ); | |
| 591 | return resolveAddProjectEnvironment(environmentOptions, environmentId); | |
| 559 | 592 | } |
| 560 | 593 | |
| 561 | 594 | export function AddProjectRepositoryScreen(props: { |
| ... | ... | @@ -619,26 +652,32 @@ export function AddProjectRepositoryScreen(props: { |
| 619 | 652 | return ( |
| 620 | 653 | <AddProjectShell> |
| 621 | 654 | {error ? <ErrorBanner message={error} /> : null} |
| 622 | <TextInput | |
| 623 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug" | |
| 624 | value={repositoryInput} | |
| 625 | onChangeText={setRepositoryInput} | |
| 626 | autoCapitalize="none" | |
| 627 | autoCorrect={false} | |
| 628 | placeholder={ | |
| 629 | source === "url" | |
| 630 | ? "https://github.com/org/repo.git" | |
| 631 | : addProjectRemoteSourcePathHint(source) | |
| 632 | } | |
| 633 | returnKeyType="next" | |
| 634 | onSubmitEditing={() => void lookupRepository()} | |
| 635 | /> | |
| 636 | <PrimaryActionButton | |
| 637 | label={source === "url" ? "Continue" : "Lookup repository"} | |
| 638 | disabled={isSubmitting || repositoryInput.trim().length === 0} | |
| 639 | onPress={() => void lookupRepository()} | |
| 640 | loading={isSubmitting} | |
| 641 | /> | |
| 655 | {environment ? ( | |
| 656 | <> | |
| 657 | <TextInput | |
| 658 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug" | |
| 659 | value={repositoryInput} | |
| 660 | onChangeText={setRepositoryInput} | |
| 661 | autoCapitalize="none" | |
| 662 | autoCorrect={false} | |
| 663 | placeholder={ | |
| 664 | source === "url" | |
| 665 | ? "https://github.com/org/repo.git" | |
| 666 | : addProjectRemoteSourcePathHint(source) | |
| 667 | } | |
| 668 | returnKeyType="next" | |
| 669 | onSubmitEditing={() => void lookupRepository()} | |
| 670 | /> | |
| 671 | <PrimaryActionButton | |
| 672 | label={source === "url" ? "Continue" : "Lookup repository"} | |
| 673 | disabled={isSubmitting || repositoryInput.trim().length === 0} | |
| 674 | onPress={() => void lookupRepository()} | |
| 675 | loading={isSubmitting} | |
| 676 | /> | |
| 677 | </> | |
| 678 | ) : ( | |
| 679 | <EmptyEnvironmentState /> | |
| 680 | )} | |
| 642 | 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 | 203 | title={screenTitle} |
| 204 | 204 | subtitle={incomingShareSubtitle} |
| 205 | 205 | onBack={layout.usesSplitView ? () => navigation.goBack() : undefined} |
| 206 | actions={[ | |
| 207 | { | |
| 208 | accessibilityLabel: "Add project", | |
| 209 | icon: "plus", | |
| 210 | onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), | |
| 211 | }, | |
| 212 | ]} | |
| 206 | actions={ | |
| 207 | catalogState.hasReadyEnvironment | |
| 208 | ? [ | |
| 209 | { | |
| 210 | accessibilityLabel: "Add project", | |
| 211 | icon: "plus", | |
| 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 | 233 | separateBackground |
| 230 | 234 | /> |
| 231 | 235 | ) : null} |
| 232 | <NativeHeaderToolbar.Button | |
| 233 | icon="plus" | |
| 234 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | |
| 235 | separateBackground | |
| 236 | /> | |
| 236 | {catalogState.hasReadyEnvironment ? ( | |
| 237 | <NativeHeaderToolbar.Button | |
| 238 | icon="plus" | |
| 239 | onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} | |
| 240 | separateBackground | |
| 241 | /> | |
| 242 | ) : null} | |
| 237 | 243 | </NativeHeaderToolbar> |
| 238 | 244 | </> |
| 239 | 245 | )} |
apps/web/src/components/CommandPalette.tsx+62-4| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 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 | 6 | import { |
| 5 | 7 | canPreloadBrowsePath, |
| 6 | 8 | createBrowseNavigationCoordinator, |
| ... | ... | @@ -163,6 +165,8 @@ interface AddProjectEnvironmentOption { |
| 163 | 165 | readonly environmentId: EnvironmentId; |
| 164 | 166 | readonly label: string; |
| 165 | 167 | readonly isPrimary: boolean; |
| 168 | readonly isConnected: boolean; | |
| 169 | readonly status: string; | |
| 166 | 170 | } |
| 167 | 171 | |
| 168 | 172 | type AddProjectRemoteProviderKind = Extract< |
| ... | ... | @@ -620,6 +624,8 @@ function OpenCommandPaletteDialog(props: { |
| 620 | 624 | runtimeLabel: environment.label, |
| 621 | 625 | }), |
| 622 | 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 | 638 | |
| 633 | 639 | return options; |
| 634 | 640 | }, [environments]); |
| 635 | const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; | |
| 641 | const defaultAddProjectEnvironmentId = | |
| 642 | addProjectEnvironmentOptions.find((option) => option.isConnected)?.environmentId ?? null; | |
| 636 | 643 | const wslAddProjectEnvironmentOption = useMemo( |
| 637 | 644 | () => |
| 638 | 645 | addProjectEnvironmentOptions.find((option) => { |
| 646 | if (!option.isConnected) { | |
| 647 | return false; | |
| 648 | } | |
| 639 | 649 | const environment = environments.find( |
| 640 | 650 | (candidate) => candidate.environmentId === option.environmentId, |
| 641 | 651 | ); |
| ... | ... | @@ -1108,6 +1118,19 @@ function OpenCommandPaletteDialog(props: { |
| 1108 | 1118 | |
| 1109 | 1119 | const startAddProjectSourceSelection = useCallback( |
| 1110 | 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 | 1134 | setAddProjectEnvironmentId(environmentId); |
| 1112 | 1135 | setAddProjectCloneFlow(null); |
| 1113 | 1136 | pushPaletteView({ |
| ... | ... | @@ -1123,6 +1146,7 @@ function OpenCommandPaletteDialog(props: { |
| 1123 | 1146 | [ |
| 1124 | 1147 | browseEnvironmentId, |
| 1125 | 1148 | buildAddProjectSourceGroups, |
| 1149 | environments, | |
| 1126 | 1150 | pushPaletteView, |
| 1127 | 1151 | sourceControlDiscovery.data, |
| 1128 | 1152 | ], |
| ... | ... | @@ -1134,7 +1158,12 @@ function OpenCommandPaletteDialog(props: { |
| 1134 | 1158 | value: `action:add-project:environment:${option.environmentId}`, |
| 1135 | 1159 | searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], |
| 1136 | 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 | 1167 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, |
| 1139 | 1168 | keepOpen: true, |
| 1140 | 1169 | run: async () => { |
| ... | ... | @@ -1155,7 +1184,7 @@ function OpenCommandPaletteDialog(props: { |
| 1155 | 1184 | ); |
| 1156 | 1185 | |
| 1157 | 1186 | const openAddProjectFlow = useCallback(() => { |
| 1158 | if (addProjectEnvironmentOptions.length > 1) { | |
| 1187 | if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) { | |
| 1159 | 1188 | pushPaletteView({ |
| 1160 | 1189 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, |
| 1161 | 1190 | groups: addProjectEnvironmentGroups, |
| ... | ... | @@ -1294,6 +1323,7 @@ function OpenCommandPaletteDialog(props: { |
| 1294 | 1323 | "environment", |
| 1295 | 1324 | ], |
| 1296 | 1325 | title: "Add project", |
| 1326 | disabled: defaultAddProjectEnvironmentId === null, | |
| 1297 | 1327 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, |
| 1298 | 1328 | keepOpen: true, |
| 1299 | 1329 | run: async () => { |
| ... | ... | @@ -1355,6 +1385,19 @@ function OpenCommandPaletteDialog(props: { |
| 1355 | 1385 | readonly platform: string; |
| 1356 | 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 | 1401 | const rawCwd = input.rawCwd; |
| 1359 | 1402 | |
| 1360 | 1403 | if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) { |
| ... | ... | @@ -1507,6 +1550,16 @@ function OpenCommandPaletteDialog(props: { |
| 1507 | 1550 | if (!addProjectCloneFlow) { |
| 1508 | 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 | 1564 | if (addProjectCloneFlow.step === "repository") { |
| 1512 | 1565 | const rawRepository = query.trim(); |
| ... | ... | @@ -1711,7 +1764,10 @@ function OpenCommandPaletteDialog(props: { |
| 1711 | 1764 | getCommandPaletteInputPlaceholder(paletteMode); |
| 1712 | 1765 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; |
| 1713 | 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 | 1771 | const willCreateProjectPath = |
| 1716 | 1772 | canSubmitBrowsePath && |
| 1717 | 1773 | !isBrowsePending && |
| ... | ... | @@ -1738,6 +1794,7 @@ function OpenCommandPaletteDialog(props: { |
| 1738 | 1794 | const canSubmitRemoteProjectFlow = |
| 1739 | 1795 | addProjectCloneFlow?.step === "repository" && |
| 1740 | 1796 | query.trim().length > 0 && |
| 1797 | canCreateProjectInEnvironment(browseEnvironment?.connection.phase) && | |
| 1741 | 1798 | !isRemoteProjectPending; |
| 1742 | 1799 | const fileManagerName = getLocalFileManagerName(navigator.platform); |
| 1743 | 1800 | const canOpenProjectFromFileManager = |
| ... | ... | @@ -2063,6 +2120,7 @@ function OpenCommandPaletteDialog(props: { |
| 2063 | 2120 | )} |
| 2064 | 2121 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} |
| 2065 | 2122 | disabled={ |
| 2123 | !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || | |
| 2066 | 2124 | relativePathNeedsActiveProject || |
| 2067 | 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 | 10 | import { |
| 11 | 11 | buildAddProjectRemoteSourceReadiness, |
| 12 | 12 | buildProjectCreateCommand, |
| 13 | canCreateProjectInEnvironment, | |
| 13 | 14 | findExistingAddProject, |
| 14 | 15 | getAddProjectInitialQuery, |
| 15 | 16 | resolveAddProjectPath, |
| ... | ... | @@ -18,6 +19,15 @@ import { |
| 18 | 19 | import type { EnvironmentProject } from "../state/models.ts"; |
| 19 | 20 | |
| 20 | 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 | 31 | it("resolves initial browse paths from settings", () => { |
| 22 | 32 | expect(getAddProjectInitialQuery("")).toBe("~/"); |
| 23 | 33 | expect(getAddProjectInitialQuery("/work")).toBe("/work/"); |
packages/client-runtime/src/operations/projects.ts+7| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; | |
| 1 | 2 | import type { |
| 2 | 3 | CommandId, |
| 3 | 4 | EnvironmentId, |
| ... | ... | @@ -27,6 +28,12 @@ export type AddProjectRemoteProviderKind = Extract< |
| 27 | 28 | >; |
| 28 | 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 | 37 | export type AddProjectRemoteSourceReadiness = Record< |
| 31 | 38 | AddProjectRemoteSource, |
| 32 | 39 | { readonly ready: boolean; readonly hint: string | null } |