Skip to content

Commit 22ed672

Browse files
author
root
committed
fix(clients): disable add project while disconnected
1 parent 3137c2b commit 22ed672

5 files changed

Lines changed: 146 additions & 27 deletions

File tree

apps/mobile/src/features/projects/AddProjectScreen.tsx

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ import {
44
addProjectRemoteSourceProvider,
55
buildAddProjectRemoteSourceReadiness,
66
buildProjectCreateCommand,
7+
canCreateProjectInEnvironment,
78
findExistingAddProject,
89
getAddProjectInitialQuery,
910
resolveAddProjectPath,
1011
sortAddProjectProviderSources,
1112
type AddProjectRemoteSource,
1213
} from "@t3tools/client-runtime/operations/projects";
14+
import {
15+
connectionStatusText,
16+
type EnvironmentConnectionPhase,
17+
} from "@t3tools/client-runtime/connection";
1318
import {
1419
canPreloadBrowsePath,
1520
createBrowseNavigationCoordinator,
@@ -46,6 +51,7 @@ import { uuidv4 } from "../../lib/uuid";
4651
import { useAtomCommand } from "../../state/use-atom-command";
4752
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
4853
import {
54+
useRemoteConnectionStatus,
4955
useRemoteEnvironmentRuntime,
5056
useSavedRemoteConnections,
5157
} from "../../state/use-remote-environment-registry";
@@ -55,6 +61,9 @@ interface EnvironmentOption {
5561
readonly label: string;
5662
readonly platform: string;
5763
readonly baseDirectory: string | null;
64+
readonly connectionState: EnvironmentConnectionPhase;
65+
readonly connectionError: string | null;
66+
readonly connectionErrorTraceId: string | null;
5867
}
5968

6069
const environmentOptionOrder = Order.mapInput(
@@ -286,19 +295,27 @@ function useBrowsePathInput(environment: EnvironmentOption | null) {
286295
function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
287296
const serverConfigByEnvironmentId = useServerConfigs();
288297
const { savedConnectionsById } = useSavedRemoteConnections();
298+
const { connectedEnvironments } = useRemoteConnectionStatus();
289299

290300
return useMemo<ReadonlyArray<EnvironmentOption>>(() => {
301+
const runtimeByEnvironmentId = new Map(
302+
connectedEnvironments.map((environment) => [environment.environmentId, environment] as const),
303+
);
291304
const options = Object.values(savedConnectionsById).map((connection) => {
292305
const config = serverConfigByEnvironmentId.get(connection.environmentId);
306+
const runtime = runtimeByEnvironmentId.get(connection.environmentId);
293307
return {
294308
environmentId: connection.environmentId,
295309
label: connection.environmentLabel,
296310
platform: platformFromOs(config?.environment.platform.os ?? null),
297311
baseDirectory: config?.settings.addProjectBaseDirectory ?? null,
312+
connectionState: runtime?.connectionState ?? "available",
313+
connectionError: runtime?.connectionError ?? null,
314+
connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null,
298315
};
299316
});
300317
return Arr.sort(options, environmentOptionOrder);
301-
}, [savedConnectionsById, serverConfigByEnvironmentId]);
318+
}, [connectedEnvironments, savedConnectionsById, serverConfigByEnvironmentId]);
302319
}
303320

304321
function useSelectedEnvironment(): {
@@ -309,8 +326,14 @@ function useSelectedEnvironment(): {
309326
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
310327
const environmentOptions = useEnvironmentOptions();
311328
const selectedEnvironment =
312-
environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ??
313-
environmentOptions[0] ??
329+
environmentOptions.find(
330+
(environment) =>
331+
environment.environmentId === selectedEnvironmentId &&
332+
canCreateProjectInEnvironment(environment.connectionState),
333+
) ??
334+
environmentOptions.find((environment) =>
335+
canCreateProjectInEnvironment(environment.connectionState),
336+
) ??
314337
null;
315338

316339
return {
@@ -325,9 +348,9 @@ function EmptyEnvironmentState() {
325348

326349
return (
327350
<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>
351+
<Text className="text-center text-lg font-t3-bold">Environment unavailable</Text>
329352
<Text className="text-center text-sm leading-normal text-foreground-muted">
330-
Add an environment before adding a project.
353+
Start or reconnect an environment before adding a project.
331354
</Text>
332355
<Pressable
333356
onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))}
@@ -407,17 +430,25 @@ export function AddProjectSourceScreen() {
407430

408431
return (
409432
<AddProjectShell>
410-
{environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null}
433+
{selectedEnvironment === null ? <EmptyEnvironmentState /> : null}
411434

412435
{environmentOptions.length > 1 ? (
413436
<>
414-
<SectionTitle>Connected environments</SectionTitle>
437+
<SectionTitle>Environments</SectionTitle>
415438
<ListSection>
416439
{environmentOptions.map((environment, index) => (
417440
<ListRow
418441
key={environment.environmentId}
419442
title={environment.label}
420-
subtitle={environment.environmentId}
443+
subtitle={
444+
canCreateProjectInEnvironment(environment.connectionState)
445+
? environment.environmentId
446+
: connectionStatusText({
447+
phase: environment.connectionState,
448+
error: environment.connectionError,
449+
traceId: environment.connectionErrorTraceId,
450+
})
451+
}
421452
icon={
422453
<SymbolView
423454
name="server.rack"
@@ -427,6 +458,7 @@ export function AddProjectSourceScreen() {
427458
/>
428459
}
429460
selected={environment.environmentId === selectedEnvironment?.environmentId}
461+
disabled={!canCreateProjectInEnvironment(environment.connectionState)}
430462
isFirst={index === 0}
431463
right={
432464
environment.environmentId === selectedEnvironment?.environmentId ? (
@@ -500,7 +532,7 @@ function useCreateProject(environment: EnvironmentOption | null) {
500532

501533
return useCallback(
502534
async (workspaceRoot: string) => {
503-
if (!environment) return;
535+
if (!environment || !canCreateProjectInEnvironment(environment.connectionState)) return;
504536

505537
const existing = findExistingAddProject({
506538
projects,
@@ -552,8 +584,14 @@ function useEnvironmentFromParam(
552584
const environmentOptions = useEnvironmentOptions();
553585
const environmentId = stringParam(environmentIdParam) as EnvironmentId | null;
554586
return (
555-
environmentOptions.find((environment) => environment.environmentId === environmentId) ??
556-
environmentOptions[0] ??
587+
environmentOptions.find(
588+
(environment) =>
589+
environment.environmentId === environmentId &&
590+
canCreateProjectInEnvironment(environment.connectionState),
591+
) ??
592+
environmentOptions.find((environment) =>
593+
canCreateProjectInEnvironment(environment.connectionState),
594+
) ??
557595
null
558596
);
559597
}

apps/mobile/src/features/threads/NewTaskRouteScreen.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,17 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
203203
title={screenTitle}
204204
subtitle={incomingShareSubtitle}
205205
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+
}
213217
/>
214218
</>
215219
) : (
@@ -229,11 +233,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
229233
separateBackground
230234
/>
231235
) : 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}
237243
</NativeHeaderToolbar>
238244
</>
239245
)}

apps/web/src/components/CommandPalette.tsx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use client";
22

33
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";
46
import {
57
canPreloadBrowsePath,
68
createBrowseNavigationCoordinator,
@@ -163,6 +165,8 @@ interface AddProjectEnvironmentOption {
163165
readonly environmentId: EnvironmentId;
164166
readonly label: string;
165167
readonly isPrimary: boolean;
168+
readonly isConnected: boolean;
169+
readonly status: string;
166170
}
167171

168172
type AddProjectRemoteProviderKind = Extract<
@@ -620,6 +624,8 @@ function OpenCommandPaletteDialog(props: {
620624
runtimeLabel: environment.label,
621625
}),
622626
isPrimary,
627+
isConnected: canCreateProjectInEnvironment(environment.connection.phase),
628+
status: connectionStatusText(environment.connection),
623629
};
624630
});
625631

@@ -632,10 +638,14 @@ function OpenCommandPaletteDialog(props: {
632638

633639
return options;
634640
}, [environments]);
635-
const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null;
641+
const defaultAddProjectEnvironmentId =
642+
addProjectEnvironmentOptions.find((option) => option.isConnected)?.environmentId ?? null;
636643
const wslAddProjectEnvironmentOption = useMemo(
637644
() =>
638645
addProjectEnvironmentOptions.find((option) => {
646+
if (!option.isConnected) {
647+
return false;
648+
}
639649
const environment = environments.find(
640650
(candidate) => candidate.environmentId === option.environmentId,
641651
);
@@ -1108,6 +1118,19 @@ function OpenCommandPaletteDialog(props: {
11081118

11091119
const startAddProjectSourceSelection = useCallback(
11101120
(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+
}
11111134
setAddProjectEnvironmentId(environmentId);
11121135
setAddProjectCloneFlow(null);
11131136
pushPaletteView({
@@ -1123,6 +1146,7 @@ function OpenCommandPaletteDialog(props: {
11231146
[
11241147
browseEnvironmentId,
11251148
buildAddProjectSourceGroups,
1149+
environments,
11261150
pushPaletteView,
11271151
sourceControlDiscovery.data,
11281152
],
@@ -1134,7 +1158,12 @@ function OpenCommandPaletteDialog(props: {
11341158
value: `action:add-project:environment:${option.environmentId}`,
11351159
searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""],
11361160
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,
11381167
icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
11391168
keepOpen: true,
11401169
run: async () => {
@@ -1155,7 +1184,7 @@ function OpenCommandPaletteDialog(props: {
11551184
);
11561185

11571186
const openAddProjectFlow = useCallback(() => {
1158-
if (addProjectEnvironmentOptions.length > 1) {
1187+
if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) {
11591188
pushPaletteView({
11601189
addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
11611190
groups: addProjectEnvironmentGroups,
@@ -1294,6 +1323,7 @@ function OpenCommandPaletteDialog(props: {
12941323
"environment",
12951324
],
12961325
title: "Add project",
1326+
disabled: defaultAddProjectEnvironmentId === null,
12971327
icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
12981328
keepOpen: true,
12991329
run: async () => {
@@ -1355,6 +1385,19 @@ function OpenCommandPaletteDialog(props: {
13551385
readonly platform: string;
13561386
readonly currentProjectCwd: string | null;
13571387
}) => {
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+
}
13581401
const rawCwd = input.rawCwd;
13591402

13601403
if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) {
@@ -1507,6 +1550,16 @@ function OpenCommandPaletteDialog(props: {
15071550
if (!addProjectCloneFlow) {
15081551
return;
15091552
}
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

15111564
if (addProjectCloneFlow.step === "repository") {
15121565
const rawRepository = query.trim();
@@ -1711,7 +1764,10 @@ function OpenCommandPaletteDialog(props: {
17111764
getCommandPaletteInputPlaceholder(paletteMode);
17121765
const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse";
17131766
const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false;
1714-
const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject;
1767+
const canSubmitBrowsePath =
1768+
isBrowsing &&
1769+
!relativePathNeedsActiveProject &&
1770+
canCreateProjectInEnvironment(browseEnvironment?.connection.phase);
17151771
const willCreateProjectPath =
17161772
canSubmitBrowsePath &&
17171773
!isBrowsePending &&
@@ -1738,6 +1794,7 @@ function OpenCommandPaletteDialog(props: {
17381794
const canSubmitRemoteProjectFlow =
17391795
addProjectCloneFlow?.step === "repository" &&
17401796
query.trim().length > 0 &&
1797+
canCreateProjectInEnvironment(browseEnvironment?.connection.phase) &&
17411798
!isRemoteProjectPending;
17421799
const fileManagerName = getLocalFileManagerName(navigator.platform);
17431800
const canOpenProjectFromFileManager =
@@ -2063,6 +2120,7 @@ function OpenCommandPaletteDialog(props: {
20632120
)}
20642121
aria-label={`${submitActionLabel} (${addShortcutLabel})`}
20652122
disabled={
2123+
!canCreateProjectInEnvironment(browseEnvironment?.connection.phase) ||
20662124
relativePathNeedsActiveProject ||
20672125
(isCloneDestinationStep && isRemoteProjectPending)
20682126
}

packages/client-runtime/src/operations/projects.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as Option from "effect/Option";
1010
import {
1111
buildAddProjectRemoteSourceReadiness,
1212
buildProjectCreateCommand,
13+
canCreateProjectInEnvironment,
1314
findExistingAddProject,
1415
getAddProjectInitialQuery,
1516
resolveAddProjectPath,
@@ -18,6 +19,15 @@ import {
1819
import type { EnvironmentProject } from "../state/models.ts";
1920

2021
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+
2131
it("resolves initial browse paths from settings", () => {
2232
expect(getAddProjectInitialQuery("")).toBe("~/");
2333
expect(getAddProjectInitialQuery("/work")).toBe("/work/");

0 commit comments

Comments
 (0)