Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic";

const ENVIRONMENT_A = EnvironmentId.make("environment-a");
const ENVIRONMENT_B = EnvironmentId.make("environment-b");

function environment(environmentId: EnvironmentId, connectionState: EnvironmentConnectionPhase) {
return { environmentId, connectionState };
}

describe("resolveAddProjectEnvironment", () => {
it("does not redirect an explicit unavailable environment to another environment", () => {
expect(
resolveAddProjectEnvironment(
[environment(ENVIRONMENT_A, "offline"), environment(ENVIRONMENT_B, "connected")],
ENVIRONMENT_A,
),
).toBeNull();
});

it("resolves an explicit connected environment", () => {
expect(
resolveAddProjectEnvironment(
[environment(ENVIRONMENT_A, "connected"), environment(ENVIRONMENT_B, "connected")],
ENVIRONMENT_A,
)?.environmentId,
).toBe(ENVIRONMENT_A);
});

it("defaults to the first connected environment when no environment is requested", () => {
expect(
resolveAddProjectEnvironment(
[environment(ENVIRONMENT_A, "offline"), environment(ENVIRONMENT_B, "connected")],
null,
)?.environmentId,
).toBe(ENVIRONMENT_B);
});
});
26 changes: 26 additions & 0 deletions apps/mobile/src/features/projects/AddProjectScreen.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import type { EnvironmentId } from "@t3tools/contracts";

export function resolveAddProjectEnvironment<
T extends {
readonly environmentId: EnvironmentId;
readonly connectionState: EnvironmentConnectionPhase;
},
>(environmentOptions: ReadonlyArray<T>, requestedEnvironmentId: EnvironmentId | null): T | null {
if (requestedEnvironmentId !== null) {
return (
environmentOptions.find(
(environment) =>
environment.environmentId === requestedEnvironmentId &&
canCreateProjectInEnvironment(environment.connectionState),
) ?? null
);
}

return (
environmentOptions.find((environment) =>
canCreateProjectInEnvironment(environment.connectionState),
) ?? null
);
}
119 changes: 79 additions & 40 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ import {
addProjectRemoteSourceProvider,
buildAddProjectRemoteSourceReadiness,
buildProjectCreateCommand,
canCreateProjectInEnvironment,
findExistingAddProject,
getAddProjectInitialQuery,
resolveAddProjectPath,
sortAddProjectProviderSources,
type AddProjectRemoteSource,
} from "@t3tools/client-runtime/operations/projects";
import {
connectionStatusText,
type EnvironmentConnectionPhase,
} from "@t3tools/client-runtime/connection";
import {
canPreloadBrowsePath,
createBrowseNavigationCoordinator,
Expand All @@ -24,7 +29,7 @@ import {
import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
import { StackActions, useNavigation } from "@react-navigation/native";
import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Arr from "effect/Array";
Expand All @@ -46,15 +51,20 @@ import { uuidv4 } from "../../lib/uuid";
import { useAtomCommand } from "../../state/use-atom-command";
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
import {
useRemoteConnectionStatus,
useRemoteEnvironmentRuntime,
useSavedRemoteConnections,
} from "../../state/use-remote-environment-registry";
import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic";

interface EnvironmentOption {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly platform: string;
readonly baseDirectory: string | null;
readonly connectionState: EnvironmentConnectionPhase;
readonly connectionError: string | null;
readonly connectionErrorTraceId: string | null;
}

const environmentOptionOrder = Order.mapInput(
Expand Down Expand Up @@ -227,10 +237,13 @@ function ProjectPathInput(props: {
}

function useBrowsePathInput(environment: EnvironmentOption | null) {
const environmentId = environment?.environmentId ?? null;
const environmentBaseDirectory = environment?.baseDirectory ?? null;
const [pathInput, commitPathInput] = useState(() =>
getAddProjectInitialQuery(environment?.baseDirectory),
getAddProjectInitialQuery(environmentBaseDirectory),
);
const environmentRuntime = useRemoteEnvironmentRuntime(environment?.environmentId ?? null);
const previousEnvironmentIdRef = useRef(environmentId);
const environmentRuntime = useRemoteEnvironmentRuntime(environmentId);
const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, {
reportFailure: false,
reportDefect: false,
Expand Down Expand Up @@ -268,10 +281,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null) {
);

useEffect(() => {
if (environment) {
setPathInput(getAddProjectInitialQuery(environment.baseDirectory));
if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) {
previousEnvironmentIdRef.current = environmentId;
setPathInput(getAddProjectInitialQuery(environmentBaseDirectory));
}
}, [environment, setPathInput]);
}, [environmentBaseDirectory, environmentId, setPathInput]);

useEffect(
() => () => {
Expand All @@ -286,19 +300,27 @@ function useBrowsePathInput(environment: EnvironmentOption | null) {
function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
const serverConfigByEnvironmentId = useServerConfigs();
const { savedConnectionsById } = useSavedRemoteConnections();
const { connectedEnvironments } = useRemoteConnectionStatus();

return useMemo<ReadonlyArray<EnvironmentOption>>(() => {
const runtimeByEnvironmentId = new Map(
connectedEnvironments.map((environment) => [environment.environmentId, environment] as const),
);
const options = Object.values(savedConnectionsById).map((connection) => {
const config = serverConfigByEnvironmentId.get(connection.environmentId);
const runtime = runtimeByEnvironmentId.get(connection.environmentId);
return {
environmentId: connection.environmentId,
label: connection.environmentLabel,
platform: platformFromOs(config?.environment.platform.os ?? null),
baseDirectory: config?.settings.addProjectBaseDirectory ?? null,
connectionState: runtime?.connectionState ?? "available",
connectionError: runtime?.connectionError ?? null,
connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null,
};
});
return Arr.sort(options, environmentOptionOrder);
}, [savedConnectionsById, serverConfigByEnvironmentId]);
}, [connectedEnvironments, savedConnectionsById, serverConfigByEnvironmentId]);
Comment thread
cursor[bot] marked this conversation as resolved.
}

function useSelectedEnvironment(): {
Expand All @@ -309,8 +331,14 @@ function useSelectedEnvironment(): {
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
const environmentOptions = useEnvironmentOptions();
const selectedEnvironment =
environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ??
environmentOptions[0] ??
environmentOptions.find(
(environment) =>
environment.environmentId === selectedEnvironmentId &&
canCreateProjectInEnvironment(environment.connectionState),
) ??
environmentOptions.find((environment) =>
canCreateProjectInEnvironment(environment.connectionState),
) ??
null;

return {
Expand All @@ -325,9 +353,9 @@ function EmptyEnvironmentState() {

return (
<View className="items-center gap-3 rounded-2xl bg-card px-5 py-8">
<Text className="text-center text-lg font-t3-bold">No environments connected</Text>
<Text className="text-center text-lg font-t3-bold">Environment unavailable</Text>
<Text className="text-center text-sm leading-normal text-foreground-muted">
Add an environment before adding a project.
Start or reconnect an environment before adding a project.
</Text>
<Pressable
onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))}
Expand Down Expand Up @@ -407,17 +435,25 @@ export function AddProjectSourceScreen() {

return (
<AddProjectShell>
{environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null}
{selectedEnvironment === null ? <EmptyEnvironmentState /> : null}

{environmentOptions.length > 1 ? (
<>
<SectionTitle>Connected environments</SectionTitle>
<SectionTitle>Environments</SectionTitle>
<ListSection>
{environmentOptions.map((environment, index) => (
<ListRow
key={environment.environmentId}
title={environment.label}
subtitle={environment.environmentId}
subtitle={
canCreateProjectInEnvironment(environment.connectionState)
? environment.environmentId
: connectionStatusText({
phase: environment.connectionState,
error: environment.connectionError,
traceId: environment.connectionErrorTraceId,
})
}
icon={
<SymbolView
name="server.rack"
Expand All @@ -427,6 +463,7 @@ export function AddProjectSourceScreen() {
/>
}
selected={environment.environmentId === selectedEnvironment?.environmentId}
disabled={!canCreateProjectInEnvironment(environment.connectionState)}
isFirst={index === 0}
right={
environment.environmentId === selectedEnvironment?.environmentId ? (
Expand Down Expand Up @@ -500,7 +537,7 @@ function useCreateProject(environment: EnvironmentOption | null) {

return useCallback(
async (workspaceRoot: string) => {
if (!environment) return;
if (!environment || !canCreateProjectInEnvironment(environment.connectionState)) return;

const existing = findExistingAddProject({
projects,
Expand Down Expand Up @@ -551,11 +588,7 @@ function useEnvironmentFromParam(
): EnvironmentOption | null {
const environmentOptions = useEnvironmentOptions();
const environmentId = stringParam(environmentIdParam) as EnvironmentId | null;
return (
environmentOptions.find((environment) => environment.environmentId === environmentId) ??
environmentOptions[0] ??
null
);
return resolveAddProjectEnvironment(environmentOptions, environmentId);
}

export function AddProjectRepositoryScreen(props: {
Expand Down Expand Up @@ -619,26 +652,32 @@ export function AddProjectRepositoryScreen(props: {
return (
<AddProjectShell>
{error ? <ErrorBanner message={error} /> : null}
<TextInput
className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
value={repositoryInput}
onChangeText={setRepositoryInput}
autoCapitalize="none"
autoCorrect={false}
placeholder={
source === "url"
? "https://github.com/org/repo.git"
: addProjectRemoteSourcePathHint(source)
}
returnKeyType="next"
onSubmitEditing={() => void lookupRepository()}
/>
<PrimaryActionButton
label={source === "url" ? "Continue" : "Lookup repository"}
disabled={isSubmitting || repositoryInput.trim().length === 0}
onPress={() => void lookupRepository()}
loading={isSubmitting}
/>
{environment ? (
<>
<TextInput
className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
value={repositoryInput}
onChangeText={setRepositoryInput}
autoCapitalize="none"
autoCorrect={false}
placeholder={
source === "url"
? "https://github.com/org/repo.git"
: addProjectRemoteSourcePathHint(source)
}
returnKeyType="next"
onSubmitEditing={() => void lookupRepository()}
/>
<PrimaryActionButton
label={source === "url" ? "Continue" : "Lookup repository"}
disabled={isSubmitting || repositoryInput.trim().length === 0}
onPress={() => void lookupRepository()}
loading={isSubmitting}
/>
</>
) : (
<EmptyEnvironmentState />
)}
</AddProjectShell>
);
}
Expand Down
30 changes: 18 additions & 12 deletions apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,17 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
title={screenTitle}
subtitle={incomingShareSubtitle}
onBack={layout.usesSplitView ? () => navigation.goBack() : undefined}
actions={[
{
accessibilityLabel: "Add project",
icon: "plus",
onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }),
},
]}
actions={
catalogState.hasReadyEnvironment
? [
{
accessibilityLabel: "Add project",
icon: "plus",
onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }),
},
]
: []
}
/>
</>
) : (
Expand All @@ -229,11 +233,13 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
separateBackground
/>
) : null}
<NativeHeaderToolbar.Button
icon="plus"
onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
separateBackground
/>
{catalogState.hasReadyEnvironment ? (
<NativeHeaderToolbar.Button
icon="plus"
onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
separateBackground
/>
) : null}
</NativeHeaderToolbar>
</>
)}
Expand Down
Loading
Loading