new eslint

This commit is contained in:
EthanShoeDev
2025-09-18 01:43:40 -04:00
parent 346f16dc17
commit 8cb3a7528a
20 changed files with 1014 additions and 388 deletions

View File

@@ -1,27 +1,115 @@
// https://docs.expo.dev/guides/using-eslint/ // https://docs.expo.dev/guides/using-eslint/
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { config as epicConfig } from '@epic-web/config/eslint'; import { config as epicConfig } from '@epic-web/config/eslint';
import eslint from '@eslint/js';
import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
import react from '@eslint-react/eslint-plugin';
import pluginQuery from '@tanstack/eslint-plugin-query';
import * as tsParser from '@typescript-eslint/parser';
import { defineConfig } from 'eslint/config'; import { defineConfig } from 'eslint/config';
import eslintReact from 'eslint-plugin-react';
import pluginReactCompiler from 'eslint-plugin-react-compiler';
import hooksPlugin from 'eslint-plugin-react-hooks';
import globals from 'globals';
import tseslint from 'typescript-eslint';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const expoConfig = require('eslint-config-expo/flat'); const expoConfig = require('eslint-config-expo/flat');
// // Both epic and expo define a 'import' plugin (though not the same package) // Several presets define the same plugin keys which causes conflicts in ESLint flat config
// // We need to pick one or they will conflict. // (e.g. 'import' from different packages, and '@typescript-eslint').
const stripImportPlugin = (config) => { // Remove conflicting plugins from upstream presets so we can control which wins.
if (!config?.plugins?.['import']) return config; const stripPlugins = (config, names) => {
const { import: _removed, ...rest } = config.plugins; if (!config?.plugins) return config;
return { const plugins = { ...config.plugins };
...config, let changed = false;
plugins: rest, for (const name of names) {
}; if (plugins[name]) {
delete plugins[name];
changed = true;
}
}
return changed ? { ...config, plugins } : config;
}; };
export default defineConfig([ export default defineConfig([
...expoConfig, // Expo (strip conflicting plugins defined elsewhere)
...epicConfig.map(stripImportPlugin), ...expoConfig.map((c) => stripPlugins(c, ['@typescript-eslint'])),
// Epic (strip conflicting plugins defined elsewhere)
...epicConfig.map((c) => stripPlugins(c, ['import', '@typescript-eslint'])),
// ts-eslint
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{ {
ignores: ['dist'], languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
// tanstack query
...pluginQuery.configs['flat/recommended'],
// @eslint-react/eslint-plugin (smaller version of eslint-plugin-react)
{
files: ['**/*.{ts,tsx}'],
...react.configs['recommended-type-checked'],
languageOptions: {
parser: tsParser,
},
},
// Lint eslint disable comments
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- no types
comments.recommended,
// eslint-plugin-react
// Terrible flat config support
{
...eslintReact.configs.flat.recommended,
files: ['**/*.{ts,tsx}'],
settings: { react: { version: 'detect' } },
languageOptions: {
...eslintReact.configs.flat.recommended?.languageOptions,
globals: {
...globals.serviceworker,
...globals.browser,
},
},
plugins: {
...eslintReact.configs.flat.recommended?.plugins,
'react-hooks': hooksPlugin,
'react-compiler': pluginReactCompiler,
},
rules: {
...hooksPlugin.configs.recommended.rules,
'react/display-name': 'off',
'react/prop-types': 'off',
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'react-compiler/react-compiler': 'error',
},
},
// Custom
{
ignores: [
'dist',
'**/*.d.ts',
'**/.expo/**',
'prettier.config.mjs',
'eslint.config.js',
],
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/restrict-template-expressions': 'off',
},
}, },
]); ]);

View File

@@ -55,7 +55,6 @@
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.7",
"p-queue": "^8.1.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.4", "react-native": "0.81.4",
@@ -76,6 +75,18 @@
"@types/react": "~19.1.12", "@types/react": "~19.1.12",
"cmd-ts": "^0.14.1", "cmd-ts": "^0.14.1",
"eslint": "^9.35.0", "eslint": "^9.35.0",
"@eslint/js": "^9.35.0",
"@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
"@eslint-react/eslint-plugin": "^1.53.0",
"@tanstack/eslint-plugin-query": "^5.86.0",
"@typescript-eslint/parser": "^8.44.0",
"@typescript-eslint/utils": "^8.43.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.4.0",
"eslint-plugin-react-refresh": "^0.4.20",
"typescript-eslint": "^8.44.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"jiti": "^2.5.1", "jiti": "^2.5.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",

View File

@@ -19,11 +19,11 @@ export const cmd = (
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout?.on('data', (data) => { proc.stdout?.on('data', (data: unknown) => {
stdout += data; stdout += String(data);
}); });
proc.stderr?.on('data', (data) => { proc.stderr?.on('data', (data: unknown) => {
stderr += data; stderr += String(data);
}); });
process.once('SIGTERM', () => { process.once('SIGTERM', () => {

View File

@@ -19,7 +19,6 @@ async function getSecrets(): Promise<{
stdio: 'pipe', stdio: 'pipe',
}, },
); );
const rawBwItem = JSON.parse(rawBwItemString);
const bwItemSchema = z.looseObject({ const bwItemSchema = z.looseObject({
login: z.looseObject({ login: z.looseObject({
username: z.string(), username: z.string(),
@@ -32,7 +31,7 @@ async function getSecrets(): Promise<{
}), }),
), ),
}); });
const bwItem = bwItemSchema.parse(rawBwItem, { const bwItem = bwItemSchema.parse(JSON.parse(rawBwItemString) as unknown, {
reportInput: true, reportInput: true,
}); });
const keystoreBase64 = bwItem.fields.find( const keystoreBase64 = bwItem.fields.find(
@@ -138,7 +137,7 @@ const signedBuildCommand = command({
.replace( .replace(
/signingConfigs \{([\s\S]*?)\}/, // Modify existing signingConfigs without removing debug /signingConfigs \{([\s\S]*?)\}/, // Modify existing signingConfigs without removing debug
(match) => { (match) => {
if (/release \{/.test(match)) { if (match.includes('release {')) {
return match.replace( return match.replace(
/release \{([\s\S]*?)\}/, /release \{([\s\S]*?)\}/,
releaseSigningConfig, releaseSigningConfig,

View File

@@ -15,7 +15,11 @@ export default function KeyManagerModalRoute() {
options={{ options={{
title: selectMode ? 'Select Key' : 'Manage Keys', title: selectMode ? 'Select Key' : 'Manage Keys',
headerRight: () => ( headerRight: () => (
<Pressable onPress={() => router.back()}> <Pressable
onPress={() => {
router.back();
}}
>
<Text style={{ color: '#E5E7EB', fontWeight: '700' }}>Close</Text> <Text style={{ color: '#E5E7EB', fontWeight: '700' }}>Close</Text>
</Pressable> </Pressable>
), ),
@@ -23,7 +27,9 @@ export default function KeyManagerModalRoute() {
/> />
<KeyList <KeyList
mode={selectMode ? 'select' : 'manage'} mode={selectMode ? 'select' : 'manage'}
onSelect={async () => router.back()} onSelect={() => {
router.back();
}}
/> />
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -237,7 +237,7 @@ function KeyIdPickerField() {
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list); const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
const defaultPick = React.useMemo(() => { const defaultPick = React.useMemo(() => {
const keys = listPrivateKeysQuery.data ?? []; const keys = listPrivateKeysQuery.data ?? [];
const def = keys.find((k) => k.metadata?.isDefault); const def = keys.find((k) => k.metadata.isDefault);
return def ?? keys[0]; return def ?? keys[0];
}, [listPrivateKeysQuery.data]); }, [listPrivateKeysQuery.data]);
const keys = listPrivateKeysQuery.data ?? []; const keys = listPrivateKeysQuery.data ?? [];
@@ -252,9 +252,9 @@ function KeyIdPickerField() {
} }
}, [fieldValue, defaultPickId, fieldHandleChange]); }, [fieldValue, defaultPickId, fieldHandleChange]);
const computedSelectedId = field.state.value ?? defaultPick?.id; const computedSelectedId = field.state.value;
const selected = keys.find((k) => k.id === computedSelectedId); const selected = keys.find((k) => k.id === computedSelectedId);
const display = selected ? (selected.metadata?.label ?? selected.id) : 'None'; const display = selected ? (selected.metadata.label ?? selected.id) : 'None';
return ( return (
<> <>
@@ -298,7 +298,9 @@ function KeyIdPickerField() {
visible={open} visible={open}
transparent transparent
animationType="slide" animationType="slide"
onRequestClose={() => setOpen(false)} onRequestClose={() => {
setOpen(false);
}}
> >
<View <View
style={{ style={{
@@ -343,7 +345,9 @@ function KeyIdPickerField() {
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
}} }}
onPress={() => setOpen(false)} onPress={() => {
setOpen(false);
}}
> >
<Text <Text
style={{ style={{
@@ -357,7 +361,7 @@ function KeyIdPickerField() {
</View> </View>
<KeyList <KeyList
mode="select" mode="select"
onSelect={async (id) => { onSelect={(id) => {
field.handleChange(id); field.handleChange(id);
setOpen(false); setOpen(false);
}} }}
@@ -399,7 +403,7 @@ function PreviousConnectionsSection(props: {
</Text> </Text>
) : listConnectionsQuery.data?.length ? ( ) : listConnectionsQuery.data?.length ? (
<View> <View>
{listConnectionsQuery.data?.map((conn) => ( {listConnectionsQuery.data.map((conn) => (
<ConnectionRow <ConnectionRow
key={conn.id} key={conn.id}
id={conn.id} id={conn.id}

View File

@@ -25,12 +25,16 @@ export default function Tab() {
<Row <Row
label="Dark" label="Dark"
selected={themeName === 'dark'} selected={themeName === 'dark'}
onPress={() => setThemeName('dark')} onPress={() => {
setThemeName('dark');
}}
/> />
<Row <Row
label="Light" label="Light"
selected={themeName === 'light'} selected={themeName === 'light'}
onPress={() => setThemeName('light')} onPress={() => {
setThemeName('light');
}}
/> />
</View> </View>
</View> </View>

View File

@@ -1,8 +1,5 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { import { type ListenerEvent } from '@fressh/react-native-uniffi-russh';
type ListenerEvent,
type TerminalChunk,
} from '@fressh/react-native-uniffi-russh';
import { import {
XtermJsWebView, XtermJsWebView,
type XtermWebViewHandle, type XtermWebViewHandle,
@@ -16,11 +13,14 @@ import {
useFocusEffect, useFocusEffect,
} from 'expo-router'; } from 'expo-router';
import React, { startTransition, useEffect, useRef, useState } from 'react'; import React, { startTransition, useEffect, useRef, useState } from 'react';
import { Pressable, View, Text } from 'react-native'; import { Dimensions, Platform, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import {
SafeAreaView,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns'; import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
import { getSession } from '@/lib/ssh-registry'; import { useSshStore, makeSessionKey } from '@/lib/ssh-store';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
export default function TabsShellDetail() { export default function TabsShellDetail() {
@@ -28,9 +28,13 @@ export default function TabsShellDetail() {
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
startTransition(() => setReady(true)); // React 19: non-urgent startTransition(() => {
setReady(true);
}); // React 19: non-urgent
return () => setReady(false); return () => {
setReady(false);
};
}, []), }, []),
); );
@@ -60,10 +64,11 @@ function ShellDetail() {
const theme = useTheme(); const theme = useTheme();
const channelIdNum = Number(channelId); const channelIdNum = Number(channelId);
const sess = const sess = useSshStore((s) =>
connectionId && channelId connectionId && channelId
? getSession(String(connectionId), channelIdNum) ? s.getByKey(makeSessionKey(connectionId, channelIdNum))
: undefined; : undefined,
);
const connection = sess?.connection; const connection = sess?.connection;
const shell = sess?.shell; const shell = sess?.shell;
@@ -74,14 +79,41 @@ function ShellDetail() {
if (shell && listenerIdRef.current != null) if (shell && listenerIdRef.current != null)
shell.removeListener(listenerIdRef.current); shell.removeListener(listenerIdRef.current);
listenerIdRef.current = null; listenerIdRef.current = null;
xterm?.flush?.(); if (xterm) xterm.flush();
}; };
}, [shell]); }, [shell]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const insets = useSafeAreaInsets();
const estimatedTabBarHeight = Platform.select({
ios: 49,
android: 80,
default: 56,
});
const windowH = Dimensions.get('window').height;
const computeBottomExtra = (y: number, height: number) => {
const extra = windowH - (y + height);
return extra > 0 ? extra : 0;
};
// Measure any bottom overlap (e.g., native tab bar) and add padding to avoid it
const [bottomExtra, setBottomExtra] = useState(0);
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}> <SafeAreaView
onLayout={(e) => {
const { y, height } = e.nativeEvent.layout;
const extra = computeBottomExtra(y, height);
if (extra !== bottomExtra) setBottomExtra(extra);
}}
style={{
flex: 1,
backgroundColor: theme.colors.background,
padding: 12,
paddingBottom:
12 + insets.bottom + (bottomExtra || estimatedTabBarHeight),
}}
>
<Stack.Screen <Stack.Screen
options={{ options={{
headerBackVisible: true, headerBackVisible: true,
@@ -107,12 +139,6 @@ function ShellDetail() {
), ),
}} }}
/> />
<View
style={[
{ flex: 1, backgroundColor: '#0B1324', padding: 12 },
{ backgroundColor: theme.colors.background },
]}
>
<XtermJsWebView <XtermJsWebView
ref={xtermRef} ref={xtermRef}
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -136,11 +162,13 @@ function ShellDetail() {
themeForeground={theme.colors.textPrimary} themeForeground={theme.colors.textPrimary}
onRenderProcessGone={() => { onRenderProcessGone={() => {
console.log('WebView render process gone -> clear()'); console.log('WebView render process gone -> clear()');
xtermRef.current?.clear?.(); const xr = xtermRef.current;
if (xr) xr.clear();
}} }}
onContentProcessDidTerminate={() => { onContentProcessDidTerminate={() => {
console.log('WKWebView content process terminated -> clear()'); console.log('WKWebView content process terminated -> clear()');
xtermRef.current?.clear?.(); const xr = xtermRef.current;
if (xr) xr.clear();
}} }}
onLoadEnd={() => { onLoadEnd={() => {
console.log('WebView onLoadEnd'); console.log('WebView onLoadEnd');
@@ -162,17 +190,21 @@ function ShellDetail() {
}); });
if (res.chunks.length) { if (res.chunks.length) {
const chunks = res.chunks.map((c) => c.bytes); const chunks = res.chunks.map((c) => c.bytes);
xtermRef.current?.writeMany?.(chunks); const xr = xtermRef.current;
xtermRef.current?.flush?.(); if (xr) {
xr.writeMany(chunks);
xr.flush();
}
} }
const id = shell.addListener( const id = shell.addListener(
(ev: ListenerEvent) => { (ev: ListenerEvent) => {
if ('kind' in ev && ev.kind === 'dropped') { if ('kind' in ev) {
console.log('listener.dropped', ev); console.log('listener.dropped', ev);
return; return;
} }
const chunk = ev as TerminalChunk; const chunk = ev;
xtermRef.current?.write(chunk.bytes); const xr3 = xtermRef.current;
if (xr3) xr3.write(chunk.bytes);
}, },
{ cursor: { mode: 'seq', seq: res.nextSeq } }, { cursor: { mode: 'seq', seq: res.nextSeq } },
); );
@@ -182,23 +214,26 @@ function ShellDetail() {
} }
// Focus to pop the keyboard (iOS needs the prop we set) // Focus to pop the keyboard (iOS needs the prop we set)
xtermRef.current?.focus?.(); const xr2 = xtermRef.current;
if (xr2) xr2.focus();
return; return;
} }
if (m.type === 'data') { if (m.type === 'data') {
console.log('xterm->SSH', { len: m.data.length }); console.log('xterm->SSH', { len: m.data.length });
// Ensure we send the exact slice; send CR only for Enter.
const { buffer, byteOffset, byteLength } = m.data; const { buffer, byteOffset, byteLength } = m.data;
const ab = buffer.slice(byteOffset, byteOffset + byteLength); const ab = buffer.slice(byteOffset, byteOffset + byteLength);
void shell?.sendData(ab as ArrayBuffer); if (shell) {
return; shell.sendData(ab as ArrayBuffer).catch((e: unknown) => {
console.warn('sendData failed', e);
router.back();
});
} }
if (m.type === 'debug') { return;
} else {
console.log('xterm.debug', m.message); console.log('xterm.debug', m.message);
} }
}} }}
/> />
</View>
</SafeAreaView> </SafeAreaView>
); );
} }

View File

@@ -1,5 +1,8 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { type SshConnection } from '@fressh/react-native-uniffi-russh'; import {
type SshShell,
type SshConnection,
} from '@fressh/react-native-uniffi-russh';
import { FlashList } from '@shopify/flash-list'; import { FlashList } from '@shopify/flash-list';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@@ -21,7 +24,6 @@ import {
listSshShellsQueryOptions, listSshShellsQueryOptions,
type ShellWithConnection, type ShellWithConnection,
} from '@/lib/query-fns'; } from '@/lib/query-fns';
import { type listConnectionsWithShells as registryList } from '@/lib/ssh-registry';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
export default function TabsShellList() { export default function TabsShellList() {
@@ -78,7 +80,7 @@ type ActionTarget =
connection: SshConnection; connection: SshConnection;
}; };
type ConnectionsList = ReturnType<typeof registryList>; type ConnectionsList = (SshConnection & { shells: SshShell[] })[];
function LoadedState({ connections }: { connections: ConnectionsList }) { function LoadedState({ connections }: { connections: ConnectionsList }) {
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>( const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
@@ -103,7 +105,9 @@ function LoadedState({ connections }: { connections: ConnectionsList }) {
)} )}
<ActionsSheet <ActionsSheet
target={actionTarget} target={actionTarget}
onClose={() => setActionTarget(null)} onClose={() => {
setActionTarget(null);
}}
onCloseShell={() => { onCloseShell={() => {
if (!actionTarget) return; if (!actionTarget) return;
if (!('shell' in actionTarget)) return; if (!('shell' in actionTarget)) return;
@@ -138,7 +142,12 @@ function FlatView({
}) { }) {
const flatShells = React.useMemo(() => { const flatShells = React.useMemo(() => {
return connectionsWithShells.reduce<ShellWithConnection[]>((acc, curr) => { return connectionsWithShells.reduce<ShellWithConnection[]>((acc, curr) => {
acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr }))); acc.push(
...curr.shells.map((shell) => ({
...shell,
connection: curr,
})),
);
return acc; return acc;
}, []); }, []);
}, [connectionsWithShells]); }, [connectionsWithShells]);
@@ -149,11 +158,11 @@ function FlatView({
renderItem={({ item }) => ( renderItem={({ item }) => (
<ShellCard <ShellCard
shell={item} shell={item}
onLongPress={() => onLongPress={() => {
setActionTarget({ setActionTarget({
shell: item, shell: item,
}) });
} }}
/> />
)} )}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />} ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
@@ -194,12 +203,12 @@ function GroupedView({
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
}} }}
onPress={() => onPress={() => {
setExpanded((prev) => ({ setExpanded((prev) => ({
...prev, ...prev,
[item.connectionId]: !prev[item.connectionId], [item.connectionId]: !prev[item.connectionId],
})) }));
} }}
> >
<View> <View>
<Text <Text
@@ -234,11 +243,11 @@ function GroupedView({
<ShellCard <ShellCard
key={`${sh.connectionId}:${sh.channelId}`} key={`${sh.connectionId}:${sh.channelId}`}
shell={shellWithConnection} shell={shellWithConnection}
onLongPress={() => onLongPress={() => {
setActionTarget({ setActionTarget({
shell: shellWithConnection, shell: shellWithConnection,
}) });
} }}
/> />
); );
})} })}
@@ -299,15 +308,15 @@ function ShellCard({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 12, paddingVertical: 12,
}} }}
onPress={() => onPress={() => {
router.push({ router.push({
pathname: '/shell/detail', pathname: '/shell/detail',
params: { params: {
connectionId: String(shell.connectionId), connectionId: shell.connectionId,
channelId: String(shell.channelId), channelId: String(shell.channelId),
}, },
}) });
} }}
onLongPress={onLongPress} onLongPress={onLongPress}
> >
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>

View File

@@ -14,15 +14,14 @@ import {
function FieldInfo() { function FieldInfo() {
const field = useFieldContext(); const field = useFieldContext();
const meta = field.state.meta; const meta = field.state.meta as { errors?: unknown[] };
const errorMessage = meta?.errors?.[0]; // TODO: typesafe errors const errs = meta.errors;
const errorMessage = errs && errs.length > 0 ? String(errs[0]) : null;
return ( return (
<View style={{ marginTop: 6 }}> <View style={{ marginTop: 6 }}>
{errorMessage ? ( {errorMessage ? (
<Text style={{ color: '#FCA5A5', fontSize: 12 }}> <Text style={{ color: '#FCA5A5', fontSize: 12 }}>{errorMessage}</Text>
{String(errorMessage)}
</Text>
) : null} ) : null}
</View> </View>
); );
@@ -103,7 +102,9 @@ export function NumberField(
]} ]}
placeholderTextColor="#9AA0A6" placeholderTextColor="#9AA0A6"
value={field.state.value.toString()} value={field.state.value.toString()}
onChangeText={(text) => field.handleChange(Number(text))} onChangeText={(text) => {
field.handleChange(Number(text));
}}
onBlur={field.handleBlur} onBlur={field.handleBlur}
{...rest} {...rest}
/> />
@@ -145,7 +146,9 @@ export function SwitchField(
style, style,
]} ]}
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.nativeEvent.value)} onChange={(event) => {
field.handleChange(event.nativeEvent.value);
}}
onBlur={field.handleBlur} onBlur={field.handleBlur}
{...rest} {...rest}
/> />
@@ -179,7 +182,7 @@ export function SubmitButton(
disabled ? { backgroundColor: '#3B82F6', opacity: 0.6 } : undefined, disabled ? { backgroundColor: '#3B82F6', opacity: 0.6 } : undefined,
]} ]}
onPress={onPress} onPress={onPress}
disabled={disabled || isSubmitting} disabled={disabled === true ? true : isSubmitting}
> >
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 16 }}> <Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 16 }}>
{isSubmitting ? 'Connecting...' : title} {isSubmitting ? 'Connecting...' : title}

View File

@@ -41,7 +41,9 @@ export function KeyList(props: {
generateMutation.isPending && { opacity: 0.7 }, generateMutation.isPending && { opacity: 0.7 },
]} ]}
disabled={generateMutation.isPending} disabled={generateMutation.isPending}
onPress={() => generateMutation.mutate()} onPress={() => {
generateMutation.mutate();
}}
> >
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 14 }}> <Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 14 }}>
{generateMutation.isPending {generateMutation.isPending
@@ -80,7 +82,7 @@ function KeyRow(props: {
const entryQuery = useQuery(secretsManager.keys.query.get(props.entryId)); const entryQuery = useQuery(secretsManager.keys.query.get(props.entryId));
const entry = entryQuery.data; const entry = entryQuery.data;
const [label, setLabel] = React.useState( const [label, setLabel] = React.useState(
entry?.manifestEntry.metadata?.label ?? '', entry?.manifestEntry.metadata.label ?? '',
); );
const renameMutation = useMutation({ const renameMutation = useMutation({
@@ -150,8 +152,8 @@ function KeyRow(props: {
> >
<View style={{ flex: 1, marginRight: 8 }}> <View style={{ flex: 1, marginRight: 8 }}>
<Text style={{ color: '#E5E7EB', fontSize: 15, fontWeight: '600' }}> <Text style={{ color: '#E5E7EB', fontSize: 15, fontWeight: '600' }}>
{entry.manifestEntry.metadata?.label ?? entry.manifestEntry.id} {entry.manifestEntry.metadata.label ?? entry.manifestEntry.id}
{entry.manifestEntry.metadata?.isDefault ? ' • Default' : ''} {entry.manifestEntry.metadata.isDefault ? ' • Default' : ''}
</Text> </Text>
<Text style={{ color: '#9AA0A6', fontSize: 12, marginTop: 2 }}> <Text style={{ color: '#9AA0A6', fontSize: 12, marginTop: 2 }}>
ID: {entry.manifestEntry.id} ID: {entry.manifestEntry.id}
@@ -179,7 +181,9 @@ function KeyRow(props: {
<View style={{ gap: 6, alignItems: 'flex-end' }}> <View style={{ gap: 6, alignItems: 'flex-end' }}>
{props.mode === 'select' ? ( {props.mode === 'select' ? (
<Pressable <Pressable
onPress={() => setDefaultMutation.mutate()} onPress={() => {
setDefaultMutation.mutate();
}}
style={{ style={{
backgroundColor: '#2563EB', backgroundColor: '#2563EB',
borderRadius: 10, borderRadius: 10,
@@ -207,7 +211,9 @@ function KeyRow(props: {
}, },
renameMutation.isPending && { opacity: 0.6 }, renameMutation.isPending && { opacity: 0.6 },
]} ]}
onPress={() => renameMutation.mutate(label)} onPress={() => {
renameMutation.mutate(label);
}}
disabled={renameMutation.isPending} disabled={renameMutation.isPending}
> >
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}> <Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
@@ -215,7 +221,7 @@ function KeyRow(props: {
</Text> </Text>
</Pressable> </Pressable>
) : null} ) : null}
{!entry.manifestEntry.metadata?.isDefault ? ( {!entry.manifestEntry.metadata.isDefault ? (
<Pressable <Pressable
style={{ style={{
backgroundColor: 'transparent', backgroundColor: 'transparent',
@@ -226,7 +232,9 @@ function KeyRow(props: {
paddingHorizontal: 10, paddingHorizontal: 10,
alignItems: 'center', alignItems: 'center',
}} }}
onPress={() => setDefaultMutation.mutate()} onPress={() => {
setDefaultMutation.mutate();
}}
> >
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}> <Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
Set Default Set Default
@@ -243,7 +251,9 @@ function KeyRow(props: {
paddingHorizontal: 10, paddingHorizontal: 10,
alignItems: 'center', alignItems: 'center',
}} }}
onPress={() => deleteMutation.mutate()} onPress={() => {
deleteMutation.mutate();
}}
> >
<Text style={{ color: '#FCA5A5', fontWeight: '700', fontSize: 12 }}> <Text style={{ color: '#FCA5A5', fontWeight: '700', fontSize: 12 }}>
Delete Delete

View File

@@ -12,7 +12,9 @@ export const preferences = {
rawTheme === 'light' ? 'light' : 'dark', rawTheme === 'light' ? 'light' : 'dark',
get: (): ThemeName => get: (): ThemeName =>
preferences.theme._resolve(storage.getString(preferences.theme._key)), preferences.theme._resolve(storage.getString(preferences.theme._key)),
set: (name: ThemeName) => storage.set(preferences.theme._key, name), set: (name: ThemeName) => {
storage.set(preferences.theme._key, name);
},
useThemePref: (): [ThemeName, (name: ThemeName) => void] => { useThemePref: (): [ThemeName, (name: ThemeName) => void] => {
const [theme, setTheme] = useMMKVString(preferences.theme._key); const [theme, setTheme] = useMMKVString(preferences.theme._key);
return [ return [
@@ -31,8 +33,9 @@ export const preferences = {
preferences.shellListViewMode._resolve( preferences.shellListViewMode._resolve(
storage.getString(preferences.shellListViewMode._key), storage.getString(preferences.shellListViewMode._key),
), ),
set: (mode: ShellListViewMode) => set: (mode: ShellListViewMode) => {
storage.set(preferences.shellListViewMode._key, mode), storage.set(preferences.shellListViewMode._key, mode);
},
useShellListViewModePref: (): [ useShellListViewModePref: (): [
ShellListViewMode, ShellListViewMode,

View File

@@ -7,11 +7,7 @@ import {
} from '@tanstack/react-query'; } from '@tanstack/react-query';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { secretsManager, type InputConnectionDetails } from './secrets-manager'; import { secretsManager, type InputConnectionDetails } from './secrets-manager';
import { import { useSshStore, toSessionStatus, type SessionKey } from './ssh-store';
listConnectionsWithShells as registryList,
registerSession,
type ShellWithConnection,
} from './ssh-registry';
import { AbortSignalTimeout } from './utils'; import { AbortSignalTimeout } from './utils';
export const useSshConnMutation = () => { export const useSshConnMutation = () => {
@@ -44,22 +40,26 @@ export const useSshConnMutation = () => {
details: connectionDetails, details: connectionDetails,
priority: 0, priority: 0,
}); });
// Capture status events to Zustand after session is known.
let keyRef: SessionKey | null = null;
const shellInterface = await sshConnection.startShell({ const shellInterface = await sshConnection.startShell({
pty: 'Xterm', pty: 'Xterm',
onStatusChange: (status) => { onStatusChange: (status) => {
if (keyRef)
useSshStore.getState().setStatus(keyRef, toSessionStatus(status));
console.log('SSH shell status', status); console.log('SSH shell status', status);
}, },
abortSignal: AbortSignalTimeout(5_000), abortSignal: AbortSignalTimeout(5_000),
}); });
const channelId = shellInterface.channelId as number; const channelId = shellInterface.channelId;
const connectionId = const connectionId = `${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
sshConnection.connectionId ??
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
console.log('Connected to SSH server', connectionId, channelId); console.log('Connected to SSH server', connectionId, channelId);
// Track in registry for app use // Track in Zustand store
registerSession(sshConnection, shellInterface); keyRef = useSshStore
.getState()
.addSession(sshConnection, shellInterface);
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: listSshShellsQueryOptions.queryKey, queryKey: listSshShellsQueryOptions.queryKey,
@@ -81,17 +81,29 @@ export const useSshConnMutation = () => {
export const listSshShellsQueryOptions = queryOptions({ export const listSshShellsQueryOptions = queryOptions({
queryKey: ['ssh-shells'], queryKey: ['ssh-shells'],
queryFn: () => registryList(), queryFn: () => useSshStore.getState().listConnectionsWithShells(),
}); });
export type { ShellWithConnection }; export type ShellWithConnection = (ReturnType<
typeof useSshStore.getState
>['listConnectionsWithShells'] extends () => infer R
? R
: never)[number]['shells'][number] & {
connection: (ReturnType<
typeof useSshStore.getState
>['listConnectionsWithShells'] extends () => infer R
? R
: never)[number];
};
export const closeSshShellAndInvalidateQuery = async (params: { export const closeSshShellAndInvalidateQuery = async (params: {
channelId: number; channelId: number;
connectionId: string; connectionId: string;
queryClient: QueryClient; queryClient: QueryClient;
}) => { }) => {
const currentActiveShells = registryList(); const currentActiveShells = useSshStore
.getState()
.listConnectionsWithShells();
const connection = currentActiveShells.find( const connection = currentActiveShells.find(
(c) => c.connectionId === params.connectionId, (c) => c.connectionId === params.connectionId,
); );
@@ -108,7 +120,9 @@ export const disconnectSshConnectionAndInvalidateQuery = async (params: {
connectionId: string; connectionId: string;
queryClient: QueryClient; queryClient: QueryClient;
}) => { }) => {
const currentActiveShells = registryList(); const currentActiveShells = useSshStore
.getState()
.listConnectionsWithShells();
const connection = currentActiveShells.find( const connection = currentActiveShells.find(
(c) => c.connectionId === params.connectionId, (c) => c.connectionId === params.connectionId,
); );

View File

@@ -79,7 +79,7 @@ function makeBetterSecureStore<
log( log(
`Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`, `Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`,
); );
const unsafedRootManifest = rawRootManifestString const unsafedRootManifest: unknown = rawRootManifestString
? JSON.parse(rawRootManifestString) ? JSON.parse(rawRootManifestString)
: { : {
manifestVersion: rootManifestVersion, manifestVersion: rootManifestVersion,
@@ -95,9 +95,11 @@ function makeBetterSecureStore<
if (!rawManifestChunkString) if (!rawManifestChunkString)
throw new Error('Manifest chunk not found'); throw new Error('Manifest chunk not found');
log( log(
`Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString?.length} bytes`, `Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString.length} bytes`,
);
const unsafedManifestChunk: unknown = JSON.parse(
rawManifestChunkString,
); );
const unsafedManifestChunk = JSON.parse(rawManifestChunkString);
return { return {
manifestChunk: manifestChunkSchema.parse(unsafedManifestChunk), manifestChunk: manifestChunkSchema.parse(unsafedManifestChunk),
manifestChunkId, manifestChunkId,
@@ -316,7 +318,7 @@ const keyMetadataSchema = z.object({
}); });
export type KeyMetadata = z.infer<typeof keyMetadataSchema>; export type KeyMetadata = z.infer<typeof keyMetadataSchema>;
const betterKeyStorage = makeBetterSecureStore<KeyMetadata, string>({ const betterKeyStorage = makeBetterSecureStore<KeyMetadata>({
storagePrefix: 'privateKey', storagePrefix: 'privateKey',
extraManifestFieldsSchema: keyMetadataSchema, extraManifestFieldsSchema: keyMetadataSchema,
parseValue: (value) => value, parseValue: (value) => value,

View File

@@ -1,108 +0,0 @@
import {
RnRussh,
type SshConnection,
type SshShell,
} from '@fressh/react-native-uniffi-russh';
// Simple in-memory registry owned by JS to track active handles.
// Keyed by `${connectionId}:${channelId}`.
export type SessionKey = string;
export type StoredSession = {
connection: SshConnection;
shell: SshShell;
};
const sessions = new Map<SessionKey, StoredSession>();
export function makeSessionKey(
connectionId: string,
channelId: number,
): SessionKey {
return `${connectionId}:${channelId}`;
}
export function registerSession(
connection: SshConnection,
shell: SshShell,
): SessionKey {
const key = makeSessionKey(connection.connectionId, shell.channelId);
sessions.set(key, { connection, shell });
return key;
}
export function getSession(
connectionId: string,
channelId: number,
): StoredSession | undefined {
return sessions.get(makeSessionKey(connectionId, channelId));
}
export function removeSession(connectionId: string, channelId: number): void {
sessions.delete(makeSessionKey(connectionId, channelId));
}
export function listSessions(): StoredSession[] {
return Array.from(sessions.values());
}
// Legacy list view expected shape
export type ShellWithConnection = StoredSession['shell'] & {
connection: SshConnection;
};
export function listConnectionsWithShells(): (SshConnection & {
shells: StoredSession['shell'][];
})[] {
// Group shells by connection
const byConn = new Map<string, { conn: SshConnection; shells: SshShell[] }>();
for (const { connection, shell } of sessions.values()) {
const g = byConn.get(connection.connectionId) ?? {
conn: connection,
shells: [],
};
g.shells.push(shell);
byConn.set(connection.connectionId, g);
}
return Array.from(byConn.values()).map(({ conn, shells }) => ({
...conn,
shells,
}));
}
// Convenience helpers for flows
export async function connectAndStart(
details: Parameters<typeof RnRussh.connect>[0],
) {
const conn = await RnRussh.connect(details);
const shell = await conn.startShell({ pty: 'Xterm' });
registerSession(conn, shell);
return { conn, shell };
}
export async function closeShell(connectionId: string, channelId: number) {
const sess = getSession(connectionId, channelId);
if (!sess) return;
await sess.shell.close();
removeSession(connectionId, channelId);
}
export async function disconnectConnection(connectionId: string) {
const remaining = Array.from(sessions.entries()).filter(
([, v]) => v.connection.connectionId === connectionId,
);
for (const [key, sess] of remaining) {
try {
await sess.shell.close();
} catch {}
sessions.delete(key);
}
// Find one connection handle for this id to disconnect
const conn = remaining[0]?.[1].connection;
if (conn) {
try {
await conn.disconnect();
} catch {}
}
}

View File

@@ -0,0 +1,86 @@
import {
type SshConnection,
type SshShell,
type SshConnectionStatus,
} from '@fressh/react-native-uniffi-russh';
import { create } from 'zustand';
export type SessionKey = string;
export const makeSessionKey = (connectionId: string, channelId: number) =>
`${connectionId}:${channelId}` as const;
export type SessionStatus = 'connecting' | 'connected' | 'disconnected';
export interface StoredSession {
connection: SshConnection;
shell: SshShell;
status: SessionStatus;
}
interface SshStoreState {
sessions: Record<SessionKey, StoredSession>;
addSession: (conn: SshConnection, shell: SshShell) => SessionKey;
removeSession: (key: SessionKey) => void;
setStatus: (key: SessionKey, status: SessionStatus) => void;
getByKey: (key: SessionKey) => StoredSession | undefined;
listConnectionsWithShells: () => (SshConnection & { shells: SshShell[] })[];
}
export const useSshStore = create<SshStoreState>((set, get) => ({
sessions: {},
addSession: (conn, shell) => {
const key = makeSessionKey(conn.connectionId, shell.channelId);
set((s) => ({
sessions: {
...s.sessions,
[key]: { connection: conn, shell, status: 'connected' },
},
}));
return key;
},
removeSession: (key) => {
set((s) => {
const { [key]: _omit, ...rest } = s.sessions;
return { sessions: rest };
});
},
setStatus: (key, status) => {
set((s) =>
s.sessions[key]
? { sessions: { ...s.sessions, [key]: { ...s.sessions[key], status } } }
: s,
);
},
getByKey: (key) => get().sessions[key],
listConnectionsWithShells: () => {
const byConn = new Map<
string,
{ conn: SshConnection; shells: SshShell[] }
>();
for (const { connection, shell } of Object.values(get().sessions)) {
const g = byConn.get(connection.connectionId) ?? {
conn: connection,
shells: [],
};
g.shells.push(shell);
byConn.set(connection.connectionId, g);
}
return Array.from(byConn.values()).map(({ conn, shells }) => ({
...conn,
shells,
}));
},
}));
export function toSessionStatus(status: SshConnectionStatus): SessionStatus {
switch (status) {
case 'shellConnecting':
return 'connecting';
case 'shellConnected':
return 'connected';
case 'shellDisconnected':
return 'disconnected';
default:
return 'connected';
}
}

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { preferences } from './preferences'; import { preferences } from './preferences';
export type AppTheme = { export interface AppTheme {
colors: { colors: {
background: string; background: string;
surface: string; surface: string;
@@ -20,7 +20,7 @@ export type AppTheme = {
shadow: string; shadow: string;
primaryDisabled: string; primaryDisabled: string;
}; };
}; }
export const darkTheme: AppTheme = { export const darkTheme: AppTheme = {
colors: { colors: {
@@ -70,11 +70,11 @@ export const themes: Record<ThemeName, AppTheme> = {
light: lightTheme, light: lightTheme,
}; };
type ThemeContextValue = { interface ThemeContextValue {
theme: AppTheme; theme: AppTheme;
themeName: ThemeName; themeName: ThemeName;
setThemeName: (name: ThemeName) => void; setThemeName: (name: ThemeName) => void;
}; }
const ThemeContext = React.createContext<ThemeContextValue | undefined>( const ThemeContext = React.createContext<ThemeContextValue | undefined>(
undefined, undefined,
@@ -93,21 +93,17 @@ export function ThemeProvider(props: { children: React.ReactNode }) {
[theme, themeName, setThemeName], [theme, themeName, setThemeName],
); );
return ( return <ThemeContext value={value}>{props.children}</ThemeContext>;
<ThemeContext.Provider value={value}>
{props.children}
</ThemeContext.Provider>
);
} }
export function useTheme() { export function useTheme() {
const ctx = React.useContext(ThemeContext); const ctx = React.use(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx.theme; return ctx.theme;
} }
export function useThemeControls() { export function useThemeControls() {
const ctx = React.useContext(ThemeContext); const ctx = React.use(ThemeContext);
if (!ctx) if (!ctx)
throw new Error('useThemeControls must be used within ThemeProvider'); throw new Error('useThemeControls must be used within ThemeProvider');
const { themeName, setThemeName } = ctx; const { themeName, setThemeName } = ctx;

View File

@@ -8,6 +8,8 @@ export const AbortSignalTimeout = (timeout: number) => {
// AbortSignal.timeout is not available as of expo 54 // AbortSignal.timeout is not available as of expo 54
// TypeError: AbortSignal.timeout is not a function (it is undefined) // TypeError: AbortSignal.timeout is not a function (it is undefined)
const controller = new AbortController(); const controller = new AbortController();
setTimeout(() => controller.abort(), timeout); setTimeout(() => {
controller.abort();
}, timeout);
return controller.signal; return controller.signal;
}; };

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html style="margin: 0; padding: 0; width: 100vw; height: 100vh"> <html style="margin: 0; padding: 0; width: 100%; height: 100%">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
@@ -7,7 +7,7 @@
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
/> />
</head> </head>
<body style="margin: 0; padding: 0; width: 100vw; height: 100vh"> <body style="margin: 0; padding: 0; width: 100%; height: 100%">
<div <div
id="terminal" id="terminal"
style="margin: 0; padding: 0; width: 100%; height: 100%" style="margin: 0; padding: 0; width: 100%; height: 100%"

614
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff