1import { AppActivityIndicator } from "../../components/AppActivityIndicator";
2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
3import { useAtomSet, useAtomValue } from "@effect/atom-react";
4import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts";
5import { AsyncResult } from "effect/reactivity";
6import { useMemo } from "react";
7import { Alert, Pressable, View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9
10import { AppText as Text } from "../../components/AppText";
11import { SymbolView } from "../../components/AppSymbol";
12import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
13import {
14 clearClientCacheAtom,
15 clientCacheSummaryAtom,
16 type EnvironmentClientCacheSummary,
17} from "../../state/client-cache-state";
18import { useServerConfigs } from "../../state/entities";
19import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
20import { SettingsActionRow } from "./components/SettingsActionRow";
21import { SettingsSection } from "./components/SettingsSection";
22import { SettingsScreen } from "./components/SettingsScreen";
23
24export function SettingsClientStorageRouteScreen() {
25 const insets = useSafeAreaInsets();
26 const summaryResult = useAtomValue(clientCacheSummaryAtom);
27 const clearResult = useAtomValue(clearClientCacheAtom);
28 const clearCache = useAtomSet(clearClientCacheAtom);
29 const { savedConnectionsById } = useSavedRemoteConnections();
30 const serverConfigs = useServerConfigs();
31 const isClearing = clearResult.waiting;
32 const summary = AsyncResult.isSuccess(summaryResult) ? summaryResult.value : null;
33 const environmentSummaries = useMemo(
34 () =>
35 [...(summary?.environments ?? [])].sort((left, right) => {
36 const leftLabel = savedConnectionsById[left.environmentId]?.environmentLabel ?? "";
37 const rightLabel = savedConnectionsById[right.environmentId]?.environmentLabel ?? "";
38 return leftLabel.localeCompare(rightLabel);
39 }),
40 [savedConnectionsById, summary?.environments],
41 );
42
43 const confirmClearEnvironment = (environment: EnvironmentClientCacheSummary) => {
44 const label =
45 savedConnectionsById[environment.environmentId]?.environmentLabel ??
46 environment.environmentId;
47 Alert.alert(
48 `Clear cache for ${label}?`,
49 "This removes offline threads, server metadata, and cached branches for this environment. The saved connection and credentials stay intact.",
50 [
51 { text: "Cancel", style: "cancel" },
52 {
53 text: "Clear Cache",
54 style: "destructive",
55 onPress: () =>
56 clearCache({ type: "environment", environmentId: environment.environmentId }),
57 },
58 ],
59 );
60 };
61
62 const confirmClearAll = () => {
63 Alert.alert(
64 "Clear all client caches?",
65 "This removes offline data for every environment. Connections, credentials, account data, and app preferences stay intact.",
66 [
67 { text: "Cancel", style: "cancel" },
68 {
69 text: "Clear All Caches",
70 style: "destructive",
71 onPress: () => clearCache({ type: "all" }),
72 },
73 ],
74 );
75 };
76
77 return (
78 <SettingsScreen title="Client Storage">
79 <ScrollView
80 contentInsetAdjustmentBehavior="automatic"
81 contentInset={{ bottom: Math.max(insets.bottom, 18) }}
82 showsVerticalScrollIndicator={false}
83 className="flex-1"
84 contentContainerClassName="gap-6 px-5 pt-4 pb-[18px]"
85 >
86 <SettingsSection title="Environment caches">
87 {AsyncResult.isFailure(summaryResult) ? (
88 <View className="items-center gap-2 px-6 py-8">
89 <SymbolView
90 name="exclamationmark.triangle"
91 size={28}
92 tintColorClassName="accent-danger-foreground"
93 type="monochrome"
94 weight="regular"
95 />
96 <Text className="text-center text-base text-foreground">Storage unavailable</Text>
97 <Text className="text-center text-sm text-foreground-muted">
98 Restart the app and try again.
99 </Text>
100 </View>
101 ) : !summary ? (
102 <View className="items-center gap-3 px-6 py-8">
103 <AppActivityIndicator />
104 <Text className="text-center text-sm text-foreground-muted">
105 Inspecting cached data…
106 </Text>
107 </View>
108 ) : environmentSummaries.length > 0 ? (
109 environmentSummaries.map((environment, index) => (
110 <CacheEnvironmentRow
111 key={environment.environmentId}
112 environment={environment}
113 environmentLabel={
114 savedConnectionsById[environment.environmentId]?.environmentLabel ??
115 environment.environmentId
116 }
117 machine={resolveEnvironmentMachineKind(
118 serverConfigs.get(environment.environmentId) ?? null,
119 )}
120 disabled={isClearing}
121 first={index === 0}
122 onClear={() => confirmClearEnvironment(environment)}
123 />
124 ))
125 ) : (
126 <View className="items-center gap-2 px-6 py-8">
127 <SymbolView
128 name="checkmark.circle"
129 size={28}
130 tintColorClassName="accent-icon"
131 type="monochrome"
132 weight="regular"
133 />
134 <Text className="text-center text-base text-foreground">No cached data</Text>
135 <Text className="text-center text-sm text-foreground-muted">
136 Offline cache records will appear here after environments are used.
137 </Text>
138 </View>
139 )}
140 </SettingsSection>
141
142 <View className="gap-3">
143 <SettingsSection title="Actions">
144 <SettingsActionRow
145 icon="trash"
146 label={summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"}
147 tone="danger"
148 disabled={isClearing || !summary || summary.recordCount === 0}
149 loading={isClearing}
150 onPress={confirmClearAll}
151 />
152 </SettingsSection>
153 <Text className="px-2 text-sm leading-normal text-foreground-muted">
154 Clearing caches never removes environment connections, credentials, account data, or
155 appearance preferences.
156 </Text>
157 {AsyncResult.isFailure(summaryResult) || AsyncResult.isFailure(clearResult) ? (
158 <Text selectable className="px-2 text-sm text-danger-foreground">
159 Client storage is temporarily unavailable. Try again after restarting the app.
160 </Text>
161 ) : null}
162 </View>
163 </ScrollView>
164 </SettingsScreen>
165 );
166}
167
168function CacheEnvironmentRow(props: {
169 readonly environment: EnvironmentClientCacheSummary;
170 readonly environmentLabel: string;
171 readonly machine: EnvironmentMachineKind;
172 readonly disabled: boolean;
173 readonly first: boolean;
174 readonly onClear: () => void;
175}) {
176 return (
177 <View
178 className={
179 props.first
180 ? "flex-row items-center gap-3 p-4"
181 : "border-t border-border flex-row items-center gap-3 p-4"
182 }
183 >
184 <EnvironmentMachineSymbol kind={props.machine} size={22} tintColorClassName="accent-icon" />
185 <Text className="min-w-0 flex-1 text-base text-foreground" numberOfLines={1}>
186 {props.environmentLabel}
187 </Text>
188 <Pressable
189 accessibilityLabel={`Clear cache for ${props.environmentLabel}`}
190 accessibilityRole="button"
191 disabled={props.disabled}
192 onPress={props.onClear}
193 className="rounded-full px-3 py-2 disabled:opacity-40"
194 >
195 <Text className="font-t3-medium tabular-nums text-danger-foreground" numberOfLines={1}>
196 Clear {formatBytes(props.environment.payloadBytes)}
197 </Text>
198 </Pressable>
199 </View>
200 );
201}
202
203function formatBytes(bytes: number): string {
204 if (bytes < 1024) return `${bytes} B`;
205 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
206 return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
207}