Skip to content
Open
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
11 changes: 11 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppea
import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsEnvironmentRenameRouteScreen } from "./features/settings/SettingsEnvironmentRenameRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
Expand Down Expand Up @@ -630,6 +631,16 @@ export const RootStack = createNativeStackNavigator({
sheetGrabberVisible: true,
},
}),
EnvironmentRename: createNativeStackScreen({
screen: SettingsEnvironmentRenameRouteScreen,
linking: "environment-rename",
options: {
title: "Rename Environment",
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.4],
sheetGrabberVisible: true,
},
}),
NewTaskSheet: createNativeStackScreen({
screen: NewTaskSheetStack,
linking: "new",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/connection/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const updateBearerConnection = createRuntimeCommand(connectionAtomRuntime
},
execute: (input: {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly label?: string;
readonly httpBaseUrl: string;
}) => ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.updateBearer(input))),
});
64 changes: 36 additions & 28 deletions apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SymbolView } from "../../components/AppSymbol";
import { connectionStatusText } from "@t3tools/client-runtime/connection";
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId } from "@t3tools/contracts";
import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useState } from "react";
Expand All @@ -14,6 +14,7 @@ import { cn } from "../../lib/cn";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
import { ConnectionStatusDot } from "./ConnectionStatusDot";
import { useEnvironmentSessionState } from "../../state/session";

function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
return connectionStatusText({
Expand All @@ -29,12 +30,12 @@ export function ConnectionEnvironmentRow(props: {
readonly onToggle: () => void;
readonly onReconnect: (environmentId: EnvironmentId) => void;
readonly onRemove: (environmentId: EnvironmentId) => void;
readonly onRename: (environmentId: EnvironmentId) => void;
readonly onUpdate: (
environmentId: EnvironmentId,
updates: { readonly label: string; readonly displayUrl: string },
updates: { readonly label?: string; readonly displayUrl: string },
) => Promise<AtomCommandResult<unknown, unknown>>;
}) {
const [label, setLabel] = useState(props.environment.environmentLabel);
const [url, setUrl] = useState(props.environment.displayUrl);

const mutedColor = useThemeColor("--color-icon-subtle");
Expand All @@ -46,9 +47,15 @@ export function ConnectionEnvironmentRow(props: {
const isRetrying =
props.environment.connectionState === "connecting" ||
props.environment.connectionState === "reconnecting";
const sessionState = useEnvironmentSessionState(props.environment.environmentId);
const canRename =
props.environment.connectionState === "connected" &&
Boolean(
sessionState.data?.authenticated &&
sessionState.data.scopes?.includes(AuthOrchestrationOperateScope),
);
const handleSave = useCallback(async () => {
const result = await props.onUpdate(props.environment.environmentId, {
label: label.trim(),
displayUrl: url.trim(),
});
if (AsyncResult.isSuccess(result)) {
Expand All @@ -60,7 +67,7 @@ export function ConnectionEnvironmentRow(props: {
"Could not update environment",
error instanceof Error ? error.message : "The environment could not be updated.",
);
}, [label, url, props]);
}, [url, props]);

return (
<Animated.View layout={LinearTransition.duration(250)} className="bg-card">
Expand Down Expand Up @@ -114,15 +121,30 @@ export function ConnectionEnvironmentRow(props: {
) : null}
</View>

<SymbolView
name="chevron.down"
size={12}
tintColor={mutedColor}
type="monochrome"
style={{
transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],
}}
/>
<View className="flex-row items-center gap-2">
{canRename ? (
<Pressable
accessibilityLabel={`Rename ${props.environment.environmentLabel}`}
accessibilityRole="button"
className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
onPress={(event) => {
event.stopPropagation();
props.onRename(props.environment.environmentId);
}}
>
<SymbolView name="pencil" size={13} tintColor={mutedColor} type="monochrome" />
</Pressable>
) : null}
<SymbolView
name="chevron.down"
size={12}
tintColor={mutedColor}
type="monochrome"
style={{
transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],
}}
/>
</View>
</Pressable>

{props.expanded ? (
Expand All @@ -137,20 +159,6 @@ export function ConnectionEnvironmentRow(props: {
</Text>
) : (
<>
<View className="gap-1.5">
<Text className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
Label
</Text>
<TextInput
autoCapitalize="words"
autoCorrect={false}
placeholder="My MacBook"
value={label}
onChangeText={setLabel}
className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-base text-foreground"
/>
</View>

<View className="gap-1.5">
<Text className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
URL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export function ConnectionsRouteScreen() {
onReconnect={onReconnectEnvironment}
onRemove={onRemoveEnvironmentPress}
onUpdate={onUpdateEnvironment}
onRename={(environmentId) =>
navigation.navigate("EnvironmentRename", { environmentId })
}
/>
</View>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ export function useConnectionController() {
const updateEnvironment = useCallback(
(
environmentId: EnvironmentId,
updates: { readonly label: string; readonly displayUrl: string },
updates: { readonly label?: string; readonly displayUrl: string },
) =>
updateBearer({
environmentId,
label: updates.label,
...(updates.label === undefined ? {} : { label: updates.label }),
httpBaseUrl: updates.displayUrl,
}),
[updateBearer],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
import type { EnvironmentId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { useState } from "react";
import { ActivityIndicator, Alert, Pressable, View } from "react-native";

import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";

export type SettingsEnvironmentRenameParams = {
readonly environmentId: EnvironmentId;
};

export function SettingsEnvironmentRenameRouteScreen({
route,
}: StaticScreenProps<SettingsEnvironmentRenameParams>) {
const navigation = useNavigation();
const { connectedEnvironments } = useRemoteConnections();
const environment = connectedEnvironments.find(
(candidate) => candidate.environmentId === route.params.environmentId,
);
const [label, setLabel] = useState(environment?.environmentLabel ?? "");
const [saving, setSaving] = useState(false);
const renameEnvironment = useAtomCommand(serverEnvironment.updateEnvironmentLabel, {
reportFailure: false,
});

const save = async () => {
const nextLabel = label.trim();
const duplicate =
nextLabel.length > 0 &&
connectedEnvironments.some(
(candidate) =>
candidate.environmentId !== route.params.environmentId &&
candidate.environmentLabel === nextLabel,
);
if (duplicate) {
Alert.alert(
"Duplicate environment name",
`Another environment is already named "${nextLabel}". Use this name for both environments?`,
[
{ text: "Cancel", style: "cancel" },
{ text: "Use Name", onPress: () => void submit(nextLabel) },
],
);
return;
}
await submit(nextLabel);
};

const submit = async (nextLabel: string) => {
setSaving(true);
const result = await renameEnvironment({
environmentId: route.params.environmentId,
input: nextLabel,
});
setSaving(false);
if (AsyncResult.isSuccess(result)) {
navigation.goBack();
return;
}
const error = Cause.squash(result.cause);
Alert.alert(
"Could not rename environment",
error instanceof Error ? error.message : "The environment name was not saved.",
);
};

return (
<View className="flex-1 gap-5 bg-sheet px-5 pt-5">
<View className="gap-1.5">
<Text className="text-sm text-foreground-muted">
Clear the name to use the environment's machine name.
</Text>
<TextInput
autoFocus
autoCapitalize="words"
autoCorrect={false}
maxLength={40}
placeholder="Environment name"
value={label}
editable={!saving && environment !== undefined}
onChangeText={setLabel}
onSubmitEditing={() => void save()}
className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-base text-foreground"
/>
</View>
<View className="flex-row gap-3">
<Pressable
className="min-h-[46px] flex-1 items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70"
disabled={saving}
onPress={() => navigation.goBack()}
>
<Text className="font-t3-bold text-foreground">Cancel</Text>
</Pressable>
<Pressable
className="min-h-[46px] flex-1 items-center justify-center rounded-[14px] bg-primary active:opacity-70 disabled:opacity-50"
disabled={saving || environment === undefined}
onPress={() => void save()}
>
{saving ? (
<ActivityIndicator color="white" />
) : (
<Text className="font-t3-bold text-primary-foreground">Save</Text>
)}
</Pressable>
</View>
</View>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function SettingsEnvironmentsRouteScreen() {
const handleUpdateEnvironment = useCallback(
(
environmentId: EnvironmentId,
updates: { readonly label: string; readonly displayUrl: string },
updates: { readonly label?: string; readonly displayUrl: string },
) => {
if (!SHOWCASE_ENABLED) return onUpdateEnvironment(environmentId, updates);
const actualEnvironment = environmentSections.localEnvironments.find(
Expand Down Expand Up @@ -145,6 +145,9 @@ export function SettingsEnvironmentsRouteScreen() {
onReconnect={onReconnectEnvironment}
onRemove={onRemoveEnvironmentPress}
onUpdate={handleUpdateEnvironment}
onRename={(environmentId) =>
navigation.navigate("EnvironmentRename", { environmentId })
}
/>
</View>
))}
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/state/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ export function projectEnvironmentPresentation(
environmentId: EnvironmentId,
presentation: BaseEnvironmentPresentation,
): EnvironmentPresentation {
const displayUrl = connectionCatalogDisplayUrl(presentation.entry);
return {
...presentation,
environmentId,
label: presentation.entry.target.label,
displayUrl: connectionCatalogDisplayUrl(presentation.entry),
label:
presentation.serverConfig?.environment.label ??
(presentation.entry.target._tag === "RelayConnectionTarget"
? presentation.entry.target.label
: (displayUrl ?? "Environment")),
displayUrl,
relayManaged: presentation.entry.target._tag === "RelayConnectionTarget",
};
}
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/state/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
import type { EnvironmentId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { Atom } from "effect/unstable/reactivity";
import { AsyncResult, Atom } from "effect/unstable/reactivity";

import { connectionAtomRuntime } from "../connection/runtime";

Expand All @@ -19,3 +19,11 @@ export function usePreparedConnection(environmentId: EnvironmentId | null) {
: environmentSession.preparedConnectionValueAtom(environmentId),
);
}

export function useEnvironmentSessionState(environmentId: EnvironmentId) {
const result = useAtomValue(environmentSession.sessionStateAtom(environmentId));
return {
data: Option.getOrNull(AsyncResult.value(result)),
isPending: result.waiting,
};
}
2 changes: 1 addition & 1 deletion apps/mobile/src/state/use-remote-environment-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export function useRemoteConnections() {
const onUpdateEnvironment = useCallback(
(
environmentId: EnvironmentId,
updates: { readonly label: string; readonly displayUrl: string },
updates: { readonly label?: string; readonly displayUrl: string },
) => controller.updateEnvironment(environmentId, updates),
[controller],
);
Expand Down
Loading
Loading