mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22:51 +00:00
theme
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
22
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user