This commit is contained in:
EthanShoeDev
2025-09-15 17:57:16 -04:00
parent 732aa5ad10
commit 1bde6fa8a2
12 changed files with 750 additions and 104 deletions

View File

@@ -35,6 +35,7 @@
"@shopify/flash-list": "2.0.2",
"@tanstack/react-form": "^1.20.0",
"@tanstack/react-query": "^5.87.4",
"date-fns": "^4.1.0",
"expo": "54.0.7",
"expo-clipboard": "~8.0.7",
"expo-constants": "~18.0.8",
@@ -57,6 +58,7 @@
"react-dom": "19.1.0",
"react-native": "0.81.4",
"react-native-gesture-handler": "~2.28.0",
"react-native-mmkv": "^3.3.1",
"react-native-reanimated": "~4.1.0",
"react-native-safe-area-context": "~5.6.1",
"react-native-screens": "~4.16.0",

View File

@@ -5,18 +5,48 @@ import { useTheme } from '@/lib/theme';
export default function TabsLayout() {
const theme = useTheme();
return (
<NativeTabs backgroundColor={theme.colors.surface}>
<NativeTabs
// common
backgroundColor={theme.colors.surface}
iconColor={theme.colors.muted}
labelStyle={{ color: theme.colors.muted }}
tintColor={theme.colors.primary}
shadowColor={theme.colors.shadow}
// android
backBehavior="initialRoute"
indicatorColor={theme.colors.primary}
labelVisibilityMode="labeled"
// rippleColor={theme.colors.transparent}
// ios
blurEffect="systemDefault"
>
<NativeTabs.Trigger name="index">
<Label>Host</Label>
<Icon sf="house.fill" drawable="ic_menu_myplaces" />
<Label selectedStyle={{ color: theme.colors.textPrimary }}>Hosts</Label>
<Icon
selectedColor={theme.colors.textPrimary}
sf="house.fill"
drawable="ic_menu_myplaces"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="shell">
<Icon sf="gear" drawable="ic_menu_compass" />
<Label>Shell</Label>
<Icon
selectedColor={theme.colors.textPrimary}
sf="gear"
drawable="ic_menu_compass"
/>
<Label selectedStyle={{ color: theme.colors.textPrimary }}>
Shells
</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Icon sf="gear" drawable="ic_menu_preferences" />
<Label>Settings</Label>
<Icon
selectedColor={theme.colors.textPrimary}
sf="gear"
drawable="ic_menu_preferences"
/>
<Label selectedStyle={{ color: theme.colors.textPrimary }}>
Settings
</Label>
</NativeTabs.Trigger>
</NativeTabs>
);

View File

@@ -22,7 +22,7 @@ import {
connectionDetailsSchema,
secretsManager,
} from '@/lib/secrets-manager';
import { useTheme ,type AppTheme } from '@/lib/theme';
import { useTheme, type AppTheme } from '@/lib/theme';
export default function TabsIndex() {
return <Host />;

View File

@@ -1,5 +1,17 @@
import { Stack } from 'expo-router';
import { useTheme } from '@/lib/theme';
export default function SettingsStackLayout() {
return <Stack />;
const theme = useTheme();
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: theme.colors.surface },
headerTitleStyle: { color: theme.colors.textPrimary },
headerTintColor: theme.colors.textPrimary,
}}
>
<Stack.Screen name="index" options={{ title: 'Settings' }} />
</Stack>
);
}

View File

@@ -1,25 +1,129 @@
import { Link } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
import React from 'react';
import { Pressable, View, Text, StyleSheet } from 'react-native';
import { useTheme, useThemeControls, type AppTheme } from '@/lib/theme';
export default function Tab() {
const theme = useTheme();
const styles = React.useMemo(() => makeStyles(theme), [theme]);
const { themeName, setThemeName } = useThemeControls();
return (
<View style={styles.container}>
<Text style={{ color: '#E5E7EB', marginBottom: 12 }}>Settings</Text>
<Link
href="/(tabs)/settings/key-manager"
style={{ color: '#60A5FA', marginBottom: 8 }}
>
Manage Keys
<View style={styles.section}>
<Text style={styles.sectionTitle}>Theme</Text>
<View style={styles.rowGroup}>
<Row
label="Dark"
selected={themeName === 'dark'}
onPress={() => setThemeName('dark')}
/>
<Row
label="Light"
selected={themeName === 'light'}
onPress={() => setThemeName('light')}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Security</Text>
<Link href="/(tabs)/settings/key-manager" asChild>
<Pressable style={styles.callout} accessibilityRole="button">
<Text style={styles.calloutLabel}>Manage Keys</Text>
<Text style={styles.calloutChevron}></Text>
</Pressable>
</Link>
</View>
</View>
);
}
const styles = StyleSheet.create({
function Row({
label,
selected,
onPress,
}: {
label: string;
selected?: boolean;
onPress: () => void;
}) {
const theme = useTheme();
const styles = React.useMemo(() => makeStyles(theme), [theme]);
return (
<Pressable
onPress={onPress}
style={[styles.row, selected && styles.rowSelected]}
accessibilityRole="button"
accessibilityState={{ selected }}
>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={styles.rowCheck}>{selected ? '✔' : ''}</Text>
</Pressable>
);
}
function makeStyles(theme: AppTheme) {
return StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#0B1324',
padding: 16,
backgroundColor: theme.colors.background,
},
});
// Title removed; screen header provides the title
section: {
marginBottom: 24,
},
sectionTitle: {
color: theme.colors.textSecondary,
fontSize: 14,
marginBottom: 8,
},
rowGroup: { gap: 8 },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: theme.colors.surface,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 12,
},
rowSelected: {
borderColor: theme.colors.primary,
},
rowLabel: {
color: theme.colors.textPrimary,
fontSize: 16,
fontWeight: '600',
},
rowCheck: {
color: theme.colors.primary,
fontSize: 16,
fontWeight: '800',
},
callout: {
backgroundColor: theme.colors.surface,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
calloutLabel: {
color: theme.colors.textPrimary,
fontSize: 16,
fontWeight: '600',
},
calloutChevron: {
color: theme.colors.muted,
fontSize: 22,
paddingHorizontal: 4,
},
});
}

View File

@@ -2,10 +2,12 @@ import { Stack } from 'expo-router';
import React from 'react';
import { SafeAreaView } from 'react-native-safe-area-context';
import { KeyList } from '@/components/key-manager/KeyList';
import { useTheme } from '@/lib/theme';
export default function SettingsKeyManager() {
const theme = useTheme();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0B1324' }}>
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<Stack.Screen options={{ title: 'Manage Keys' }} />
<KeyList mode="manage" />
</SafeAreaView>

View File

@@ -1,19 +1,22 @@
import { Stack } from 'expo-router';
import React from 'react';
import { useTheme } from '@/lib/theme';
export default function TabsShellStack() {
const theme = useTheme();
return (
<Stack
screenOptions={{
headerBlurEffect: 'systemMaterial',
headerTransparent: true,
headerBlurEffect: undefined,
headerTransparent: false,
headerStyle: { backgroundColor: theme.colors.surface },
headerTitleStyle: {
color: theme.colors.textPrimary,
},
}}
>
<Stack.Screen name="index" options={{ title: 'Shells' }} />
<Stack.Screen
name="[connectionId]/[channelId]"
options={{ title: 'SSH Shell' }}
/>
<Stack.Screen name="detail" options={{ title: 'SSH Shell' }} />
</Stack>
);
}

View File

@@ -1,3 +1,4 @@
import { Ionicons } from '@expo/vector-icons';
import { RnRussh } from '@fressh/react-native-uniffi-russh';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useRef, useState } from 'react';
@@ -19,24 +20,27 @@ export default function TabsShellDetail() {
function ShellDetail() {
const { connectionId, channelId } = useLocalSearchParams<{
connectionId: string;
channelId: string;
connectionId?: string;
channelId?: string;
}>();
const router = useRouter();
const theme = useTheme();
const channelIdNum = Number(channelId);
const connection = RnRussh.getSshConnection(connectionId);
const shell = RnRussh.getSshShell(connectionId, channelIdNum);
const connection = connectionId
? RnRussh.getSshConnection(String(connectionId))
: undefined;
const shell =
connectionId && channelId
? RnRussh.getSshShell(String(connectionId), channelIdNum)
: undefined;
const [shellData, setShellData] = useState('');
// Subscribe to data frames on the connection
useEffect(() => {
if (!connection) return;
const decoder = new TextDecoder('utf-8');
const channelListenerId = connection.addChannelListener(
(data: ArrayBuffer) => {
const listenerId = connection.addChannelListener((data: ArrayBuffer) => {
try {
const bytes = new Uint8Array(data);
const chunk = decoder.decode(bytes);
@@ -44,15 +48,13 @@ function ShellDetail() {
} catch (e) {
console.warn('Failed to decode shell data', e);
}
},
);
});
return () => {
connection.removeChannelListener(channelListenerId);
connection.removeChannelListener(listenerId);
};
}, [connection]);
const scrollViewRef = useRef<ScrollView | null>(null);
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [shellData]);
@@ -63,16 +65,17 @@ function ShellDetail() {
options={{
headerLeft: () => (
<Pressable
onPress={async () => {
router.back();
}}
onPress={() => router.back()}
hitSlop={10}
style={{ paddingHorizontal: 4, paddingVertical: 4 }}
>
<Text style={{ color: theme.colors.primary, fontWeight: '700' }}>
Back
</Text>
<Ionicons
name="chevron-back"
size={22}
color={theme.colors.textPrimary}
/>
</Pressable>
),
headerRight: () => (
<Pressable
onPress={async () => {

View File

@@ -1,16 +1,18 @@
import { Ionicons } from '@expo/vector-icons';
import {
type RnRussh,
RnRussh,
type SshConnection,
type SshShellSession,
} from '@fressh/react-native-uniffi-russh';
import { FlashList } from '@shopify/flash-list';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'expo-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { Link, Stack, useRouter } from 'expo-router';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { listSshShellsQueryOptions } from '@/lib/query-fns';
import { useTheme ,type AppTheme } from '@/lib/theme';
import { useTheme, type AppTheme } from '@/lib/theme';
export default function TabsShellList() {
const theme = useTheme();
@@ -24,53 +26,204 @@ export default function TabsShellList() {
type ShellWithConnection = SshShellSession & { connection: SshConnection };
function ShellContent() {
const [viewIndex, setViewIndex] = React.useState(0);
const connectionsWithShells = useQuery(listSshShellsQueryOptions);
if (!connectionsWithShells.data) {
return <LoadingState />;
}
return <LoadedState connectionsWithShells={connectionsWithShells.data} />;
return (
<View style={{ flex: 1 }}>
<Stack.Screen
options={{
headerRight: () => (
<TopBarToggle viewIndex={viewIndex} onChange={setViewIndex} />
),
}}
/>
{connectionsWithShells.isLoading || !connectionsWithShells.data ? (
<LoadingState />
) : (
<LoadedState
connectionsWithShells={connectionsWithShells.data}
viewIndex={viewIndex}
/>
)}
</View>
);
}
function LoadingState() {
const theme = useTheme();
const styles = React.useMemo(() => makeStyles(theme), [theme]);
return (
<View style={styles.container}>
<Text style={styles.text}>Loading...</Text>
<View style={styles.centerContent}>
<Text style={styles.mutedText}>Loading...</Text>
</View>
);
}
function LoadedState({
connectionsWithShells,
viewIndex,
}: {
connectionsWithShells: ReturnType<
typeof RnRussh.listSshConnectionsWithShells
>;
viewIndex: number;
}) {
const shellsFirstList = connectionsWithShells.reduce<ShellWithConnection[]>(
(acc, curr) => {
const theme = useTheme();
const styles = React.useMemo(() => makeStyles(theme), [theme]);
const [actionTarget, setActionTarget] = React.useState<null | {
connectionId: string;
channelId: number;
}>(null);
const queryClient = useQueryClient();
const flatShells = React.useMemo(() => {
return connectionsWithShells.reduce<ShellWithConnection[]>((acc, curr) => {
acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr })));
return acc;
},
[],
);
}, []);
}, [connectionsWithShells]);
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
React.useEffect(() => {
const init: Record<string, boolean> = {};
for (const c of connectionsWithShells) init[c.connectionId] = true;
setExpanded(init);
}, [connectionsWithShells]);
async function handleCloseShell(connId: string, channelId: number) {
try {
const shell = RnRussh.getSshShell(connId, channelId);
await (shell as any)?.close?.();
} catch {}
await queryClient.invalidateQueries({
queryKey: listSshShellsQueryOptions.queryKey,
});
setActionTarget(null);
}
async function handleDisconnect(connId: string) {
try {
const conn = RnRussh.getSshConnection(connId);
await conn?.disconnect();
} catch {}
await queryClient.invalidateQueries({
queryKey: listSshShellsQueryOptions.queryKey,
});
setActionTarget(null);
}
if (viewIndex === 0) {
return (
<View style={{ flex: 1 }}>
{shellsFirstList.length === 0 ? (
{flatShells.length === 0 ? (
<EmptyState />
) : (
<FlashList
data={shellsFirstList}
data={flatShells}
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
renderItem={({ item }) => <ShellCard shell={item} />}
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
contentContainerStyle={{ paddingVertical: 16 }}
renderItem={({ item }) => (
<ShellCard
shell={item}
onLongPress={() =>
setActionTarget({
connectionId: item.connectionId as string,
channelId: item.channelId as number,
})
}
/>
)}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
contentContainerStyle={{
paddingVertical: 16,
paddingHorizontal: 16,
}}
style={{ flex: 1 }}
/>
)}
<ActionsSheet
target={actionTarget}
onClose={() => setActionTarget(null)}
onCloseShell={() =>
actionTarget &&
handleCloseShell(actionTarget.connectionId, actionTarget.channelId)
}
onDisconnect={() =>
actionTarget && handleDisconnect(actionTarget.connectionId)
}
/>
</View>
);
}
return (
<View style={{ flex: 1 }}>
{connectionsWithShells.length === 0 ? (
<EmptyState />
) : (
<FlashList
data={connectionsWithShells}
// estimatedItemSize={80}
keyExtractor={(item) => item.connectionId}
renderItem={({ item }) => (
<View style={styles.groupContainer}>
<Pressable
style={styles.groupHeader}
onPress={() =>
setExpanded((prev) => ({
...prev,
[item.connectionId]: !prev[item.connectionId],
}))
}
>
<View>
<Text style={styles.groupTitle}>
{item.connectionDetails.username}@
{item.connectionDetails.host}
</Text>
<Text style={styles.groupSubtitle}>
Port {item.connectionDetails.port} {item.shells.length}{' '}
shell{item.shells.length === 1 ? '' : 's'}
</Text>
</View>
<Text style={styles.groupChevron}>
{expanded[item.connectionId] ? '▾' : '▸'}
</Text>
</Pressable>
{expanded[item.connectionId] && (
<View style={{ gap: 12 }}>
{item.shells.map((sh) => (
<ShellCard
key={`${sh.connectionId}:${sh.channelId}`}
shell={{ ...sh, connection: item }}
onLongPress={() =>
setActionTarget({
connectionId: sh.connectionId as string,
channelId: sh.channelId as number,
})
}
/>
))}
</View>
)}
</View>
)}
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
contentContainerStyle={{ paddingVertical: 16, paddingHorizontal: 16 }}
style={{ flex: 1 }}
/>
)}
<ActionsSheet
target={actionTarget}
onClose={() => setActionTarget(null)}
onCloseShell={() =>
actionTarget &&
handleCloseShell(actionTarget.connectionId, actionTarget.channelId)
}
onDisconnect={() =>
actionTarget && handleDisconnect(actionTarget.connectionId)
}
/>
</View>
);
}
@@ -79,36 +232,291 @@ function EmptyState() {
const theme = useTheme();
const styles = React.useMemo(() => makeStyles(theme), [theme]);
return (
<View style={styles.container}>
<Text style={styles.text}>No active shells. Connect from Host tab.</Text>
<Link href="/">Go to Host</Link>
<View style={styles.centerContent}>
<Text style={styles.mutedText}>
No active shells. Connect from Host tab.
</Text>
<Link href="/" style={styles.link}>
Go to Hosts
</Link>
</View>
);
}
function ShellCard({ shell }: { shell: ShellWithConnection }) {
function ShellCard({
shell,
onLongPress,
}: {
shell: ShellWithConnection;
onLongPress?: () => void;
}) {
const theme = useTheme();
const styles = React.useMemo(() => makeStyles(theme), [theme]);
const router = useRouter();
const since = formatDistanceToNow(new Date(shell.createdAtMs), {
addSuffix: true,
});
return (
<View style={styles.container}>
<Text style={styles.text}>{shell.connectionId}</Text>
<Text style={styles.text}>{shell.createdAtMs}</Text>
<Text style={styles.text}>{shell.pty}</Text>
<Text style={styles.text}>{shell.connection.connectionDetails.host}</Text>
<Text style={styles.text}>{shell.connection.connectionDetails.port}</Text>
<Text style={styles.text}>
{shell.connection.connectionDetails.username}
<Pressable
style={styles.card}
onPress={() =>
router.push({
pathname: '/shell/detail',
params: {
connectionId: String(shell.connectionId),
channelId: String(shell.channelId),
},
})
}
onLongPress={onLongPress}
>
<View style={{ flex: 1 }}>
<Text style={styles.cardTitle} numberOfLines={1}>
{shell.connection.connectionDetails.username}@
{shell.connection.connectionDetails.host}
</Text>
<Text style={styles.text}>
{shell.connection.connectionDetails.security.type}
<Text style={styles.cardSubtitle} numberOfLines={1}>
Port {shell.connection.connectionDetails.port} {shell.pty}
</Text>
<Text style={styles.cardMeta}>Started {since}</Text>
</View>
<Text style={styles.cardChevron}></Text>
</Pressable>
);
}
function ActionsSheet({
target,
onClose,
onCloseShell,
onDisconnect,
}: {
target: null | { connectionId: string; channelId: number };
onClose: () => void;
onCloseShell: () => void;
onDisconnect: () => void;
}) {
const theme = useTheme();
const styles = React.useMemo(() => makeStyles(theme), [theme]);
const open = !!target;
return (
<Modal
transparent
visible={open}
animationType="slide"
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<Text style={styles.title}>Shell Actions</Text>
<View style={{ height: 12 }} />
<Pressable style={styles.primaryButton} onPress={onCloseShell}>
<Text style={styles.primaryButtonText}>Close Shell</Text>
</Pressable>
<View style={{ height: 8 }} />
<Pressable style={styles.secondaryButton} onPress={onDisconnect}>
<Text style={styles.secondaryButtonText}>
Disconnect Connection
</Text>
</Pressable>
<View style={{ height: 8 }} />
<Pressable style={styles.secondaryButton} onPress={onClose}>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</Pressable>
</View>
</View>
</Modal>
);
}
function makeStyles(theme: AppTheme) {
return StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
text: { color: theme.colors.textPrimary, marginBottom: 8 },
centerContent: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
mutedText: { color: theme.colors.muted },
link: { color: theme.colors.primary, fontWeight: '600' },
// headerBar/title removed in favor of TopBarToggle
groupContainer: {
gap: 12,
},
groupHeader: {
backgroundColor: theme.colors.surface,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
groupTitle: {
color: theme.colors.textPrimary,
fontSize: 16,
fontWeight: '700',
},
groupSubtitle: {
color: theme.colors.muted,
fontSize: 12,
marginTop: 2,
},
groupChevron: {
color: theme.colors.muted,
fontSize: 18,
},
card: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: theme.colors.inputBackground,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
},
cardTitle: {
color: theme.colors.textPrimary,
fontSize: 15,
fontWeight: '600',
},
cardSubtitle: {
color: theme.colors.textSecondary,
fontSize: 12,
marginTop: 2,
},
cardMeta: {
color: theme.colors.muted,
fontSize: 12,
marginTop: 6,
},
cardChevron: {
color: theme.colors.muted,
fontSize: 22,
paddingHorizontal: 4,
},
modalOverlay: {
flex: 1,
backgroundColor: theme.colors.overlay,
justifyContent: 'flex-end',
},
modalSheet: {
backgroundColor: theme.colors.background,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: 16,
borderColor: theme.colors.borderStrong,
borderWidth: 1,
},
title: {
color: theme.colors.textPrimary,
fontSize: 18,
fontWeight: '700',
},
primaryButton: {
backgroundColor: theme.colors.primary,
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
},
primaryButtonText: {
color: theme.colors.buttonTextOnPrimary,
fontWeight: '700',
fontSize: 14,
letterSpacing: 0.3,
},
secondaryButton: {
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
},
secondaryButtonText: {
color: theme.colors.textSecondary,
fontWeight: '600',
fontSize: 14,
letterSpacing: 0.3,
},
});
}
function TopBarToggle({
viewIndex,
onChange,
}: {
viewIndex: number;
onChange: (index: number) => void;
}) {
const theme = useTheme();
const styles = React.useMemo(
() =>
StyleSheet.create({
container: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 8,
alignItems: 'flex-end',
},
toggle: {
flexDirection: 'row',
backgroundColor: theme.colors.surface,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
overflow: 'hidden',
},
segment: {
paddingHorizontal: 10,
paddingVertical: 6,
alignItems: 'center',
justifyContent: 'center',
},
active: {
backgroundColor: theme.colors.inputBackground,
},
iconActive: { color: theme.colors.textPrimary },
iconInactive: { color: theme.colors.muted },
}),
[theme],
);
return (
<View style={styles.container}>
<View style={styles.toggle}>
<Pressable
accessibilityLabel="Flat list"
onPress={() => onChange(0)}
style={[styles.segment, viewIndex === 0 && styles.active]}
>
<Ionicons
name="list"
size={18}
style={viewIndex === 0 ? styles.iconActive : styles.iconInactive}
/>
</Pressable>
<Pressable
accessibilityLabel="Grouped by connection"
onPress={() => onChange(1)}
style={[styles.segment, viewIndex === 1 && styles.active]}
>
<Ionicons
name="git-branch"
size={18}
style={viewIndex === 1 ? styles.iconActive : styles.iconInactive}
/>
</Pressable>
</View>
</View>
);
}

View File

@@ -56,7 +56,7 @@ export const useSshConnMutation = () => {
queryKey: listSshShellsQueryOptions.queryKey,
});
router.push({
pathname: '/shell/[connectionId]/[channelId]',
pathname: '/shell/detail',
params: {
connectionId: connectionId,
channelId: String(channelId),

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { MMKV } from 'react-native-mmkv';
export type AppTheme = {
colors: {
@@ -42,8 +43,38 @@ export const darkTheme: AppTheme = {
},
};
export const lightTheme: AppTheme = {
colors: {
background: '#F9FAFB',
surface: '#FFFFFF',
terminalBackground: '#F3F4F6',
border: '#E5E7EB',
borderStrong: '#D1D5DB',
textPrimary: '#111827',
textSecondary: '#374151',
muted: '#6B7280',
primary: '#2563EB',
buttonTextOnPrimary: '#FFFFFF',
inputBackground: '#FFFFFF',
danger: '#DC2626',
overlay: 'rgba(0,0,0,0.2)',
transparent: 'transparent',
shadow: '#000000',
primaryDisabled: '#93C5FD',
},
};
export type ThemeName = 'dark' | 'light';
export const themes: Record<ThemeName, AppTheme> = {
dark: darkTheme,
light: lightTheme,
};
type ThemeContextValue = {
theme: AppTheme;
themeName: ThemeName;
setThemeName: (name: ThemeName) => void;
// Back-compat; not used externally but kept to avoid breaking imports
setTheme: (theme: AppTheme) => void;
};
@@ -51,10 +82,31 @@ const ThemeContext = React.createContext<ThemeContextValue | undefined>(
undefined,
);
export function ThemeProvider(props: { children: React.ReactNode }) {
const [theme, setTheme] = React.useState<AppTheme>(darkTheme);
const storage = new MMKV({ id: 'settings' });
const THEME_KEY = 'theme';
const value = React.useMemo(() => ({ theme, setTheme }), [theme]);
export function ThemeProvider(props: { children: React.ReactNode }) {
const [themeName, setThemeName] = React.useState<ThemeName>(() => {
const stored = storage.getString(THEME_KEY);
return stored === 'light' ? 'light' : 'dark';
});
const theme = themes[themeName];
const handleSetThemeName = React.useCallback((name: ThemeName) => {
setThemeName(name);
storage.set(THEME_KEY, name);
}, []);
const value = React.useMemo<ThemeContextValue>(
() => ({
theme,
themeName,
setThemeName: handleSetThemeName,
setTheme: () => {},
}),
[theme, themeName, handleSetThemeName],
);
return (
<ThemeContext.Provider value={value}>
@@ -68,3 +120,11 @@ export function useTheme() {
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx.theme;
}
export function useThemeControls() {
const ctx = React.useContext(ThemeContext);
if (!ctx)
throw new Error('useThemeControls must be used within ThemeProvider');
const { themeName, setThemeName } = ctx;
return { themeName, setThemeName };
}

22
pnpm-lock.yaml generated
View File

@@ -70,6 +70,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.87.4
version: 5.87.4(react@19.1.0)
date-fns:
specifier: ^4.1.0
version: 4.1.0
expo:
specifier: 54.0.7
version: 54.0.7(@babel/core@7.28.3)(@expo/metro-runtime@6.1.1)(expo-router@6.0.4)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
@@ -136,6 +139,9 @@ importers:
react-native-gesture-handler:
specifier: ~2.28.0
version: 2.28.0(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
react-native-mmkv:
specifier: ^3.3.1
version: 3.3.1(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
react-native-reanimated:
specifier: ~4.1.0
version: 4.1.0(@babel/core@7.28.3)(react-native-worklets@0.5.1(@babel/core@7.28.3)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0)
@@ -4066,6 +4072,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.18:
resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==}
@@ -7179,6 +7188,12 @@ packages:
react: '*'
react-native: '*'
react-native-mmkv@3.3.1:
resolution: {integrity: sha512-LYamDWQirPTUJZ9Re+BkCD+zLRGNr+EVJDeIeblvoJXGatWy9PXnChtajDSLqwjX3EXVeUyjgrembs7wlBw9ug==}
peerDependencies:
react: '*'
react-native: '*'
react-native-monorepo-config@0.1.10:
resolution: {integrity: sha512-v0rlaLZiCUg95Mpw6xNRQce5k9yio0qscKjNQaPtFYMNL75YugS2UPUItIPLIRbZubK+s2/LRzBjX+mdyUgh4g==}
@@ -13388,6 +13403,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns@4.1.0: {}
dayjs@1.11.18: {}
debug@2.6.9:
@@ -17286,6 +17303,11 @@ snapshots:
react: 19.1.0
react-native: 0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0)
react-native-mmkv@3.3.1(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-native: 0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0)
react-native-monorepo-config@0.1.10:
dependencies:
escape-string-regexp: 5.0.0