authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-07-30 04:14:06+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-29 22:14:06-04:00
log758decaa565f021f85af756669b18bd9f80cacf5
tree2e4d4a9bcf0e615ced31e20be6bbe2bad7b3a6cd
parent2d9066e089c241c3befd7a4632efe493fabd70d9
signature Signed by PGP key B5690EEEBB952194

fix(clients): disable add project while disconnected (#4834)


7 files changed, 243 insertions(+), 56 deletions(-)

apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts created+41
...@@ -0,0 +1,41 @@
1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import { EnvironmentId } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic";
6
7const ENVIRONMENT_A = EnvironmentId.make("environment-a");
8const ENVIRONMENT_B = EnvironmentId.make("environment-b");
9
10function environment(environmentId: EnvironmentId, connectionState: EnvironmentConnectionPhase) {
11 return { environmentId, connectionState };
12}
13
14describe("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 @@
1import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
2import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
3import type { EnvironmentId } from "@t3tools/contracts";
4
5export 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";
14import {
15 connectionStatusText,
16 type EnvironmentConnectionPhase,
17} from "@t3tools/client-runtime/connection";
13import {18import {
14 canPreloadBrowsePath,19 canPreloadBrowsePath,
15 createBrowseNavigationCoordinator,20 createBrowseNavigationCoordinator,
...@@ -24,7 +29,7 @@ import {...@@ -24,7 +29,7 @@ import {
24import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";29import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
25import { StackActions, useNavigation } from "@react-navigation/native";30import { StackActions, useNavigation } from "@react-navigation/native";
26import { SymbolView } from "../../components/AppSymbol";31import { SymbolView } from "../../components/AppSymbol";
27import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";32import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
28import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";33import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
29import { useSafeAreaInsets } from "react-native-safe-area-context";34import { useSafeAreaInsets } from "react-native-safe-area-context";
30import * as Arr from "effect/Array";35import * as Arr from "effect/Array";
...@@ -46,15 +51,20 @@ import { uuidv4 } from "../../lib/uuid";...@@ -46,15 +51,20 @@ import { uuidv4 } from "../../lib/uuid";
46import { useAtomCommand } from "../../state/use-atom-command";51import { useAtomCommand } from "../../state/use-atom-command";
47import { useAtomQueryRunner } from "../../state/use-atom-query-runner";52import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
48import {53import {
54 useRemoteConnectionStatus,
49 useRemoteEnvironmentRuntime,55 useRemoteEnvironmentRuntime,
50 useSavedRemoteConnections,56 useSavedRemoteConnections,
51} from "../../state/use-remote-environment-registry";57} from "../../state/use-remote-environment-registry";
58import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic";
5259
53interface EnvironmentOption {60interface 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}
5969
60const environmentOptionOrder = Order.mapInput(70const environmentOptionOrder = Order.mapInput(
...@@ -227,10 +237,13 @@ function ProjectPathInput(props: {...@@ -227,10 +237,13 @@ function ProjectPathInput(props: {
227}237}
228238
229function useBrowsePathInput(environment: EnvironmentOption | null) {239function 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 );
269282
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]);
275289
276 useEffect(290 useEffect(
277 () => () => {291 () => () => {
...@@ -286,19 +300,27 @@ function useBrowsePathInput(environment: EnvironmentOption | null) {...@@ -286,19 +300,27 @@ function useBrowsePathInput(environment: EnvironmentOption | null) {
286function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {300function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
287 const serverConfigByEnvironmentId = useServerConfigs();301 const serverConfigByEnvironmentId = useServerConfigs();
288 const { savedConnectionsById } = useSavedRemoteConnections();302 const { savedConnectionsById } = useSavedRemoteConnections();
303 const { connectedEnvironments } = useRemoteConnectionStatus();
289304
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}
303325
304function useSelectedEnvironment(): {326function 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;
315343
316 return {344 return {
...@@ -325,9 +353,9 @@ function EmptyEnvironmentState() {...@@ -325,9 +353,9 @@ function EmptyEnvironmentState() {
325353
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 <Pressable360 <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() {
407435
408 return (436 return (
409 <AddProjectShell>437 <AddProjectShell>
410 {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null}438 {selectedEnvironment === null ? <EmptyEnvironmentState /> : null}
411439
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 <ListRow445 <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 <SymbolView458 <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) {
500537
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;
504541
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}
560593
561export function AddProjectRepositoryScreen(props: {594export 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 <TextInput655 {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 <PrimaryActionButton669 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 separateBackground233 separateBackground
230 />234 />
231 ) : null}235 ) : null}
232 <NativeHeaderToolbar.Button236 {catalogState.hasReadyEnvironment ? (
233 icon="plus"237 <NativeHeaderToolbar.Button
234 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}238 icon="plus"
235 separateBackground239 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";
22
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
5import { connectionStatusText } from "@t3tools/client-runtime/connection";
4import {6import {
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}
167171
168type AddProjectRemoteProviderKind = Extract<172type 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 });
625631
...@@ -632,10 +638,14 @@ function OpenCommandPaletteDialog(props: {...@@ -632,10 +638,14 @@ function OpenCommandPaletteDialog(props: {
632638
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: {
11081118
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 );
11561185
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;
13591402
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 }
15101563
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";
10import {10import {
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 {
18import type { EnvironmentProject } from "../state/models.ts";19import type { EnvironmentProject } from "../state/models.ts";
1920
20describe("add project shared logic", () => {21describe("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 @@
1import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
1import type {2import 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>;
28export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";29export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
2930
31export function canCreateProjectInEnvironment(
32 connectionPhase: EnvironmentConnectionPhase | null | undefined,
33): boolean {
34 return connectionPhase === "connected";
35}
36
30export type AddProjectRemoteSourceReadiness = Record<37export type AddProjectRemoteSourceReadiness = Record<
31 AddProjectRemoteSource,38 AddProjectRemoteSource,
32 { readonly ready: boolean; readonly hint: string | null }39 { readonly ready: boolean; readonly hint: string | null }