From d664dc26c088834eb392b83eac28cff591be9435 Mon Sep 17 00:00:00 2001 From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com> Date: Thu, 18 Sep 2025 02:27:05 -0400 Subject: [PATCH] more working --- .jscpd.json | 3 +- .vscode/settings.json | 3 +- apps/mobile/eslint.config.js | 5 +- apps/mobile/src/app/(tabs)/shell/detail.tsx | 27 +++-- .../mobile/src/components/form-components.tsx | 1 + .../eslint.config.js | 85 +++++++++++--- .../react-native-xtermjs-webview/package.json | 6 +- .../src-internal/main.tsx | 73 +++++++----- .../src/bridge.ts | 25 ++++ .../src/index.tsx | 110 +++++++++--------- .../vite.config.ts | 20 ++-- pnpm-lock.yaml | 12 ++ 12 files changed, 251 insertions(+), 119 deletions(-) create mode 100644 packages/react-native-xtermjs-webview/src/bridge.ts diff --git a/.jscpd.json b/.jscpd.json index 220a753..7f97265 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -9,7 +9,8 @@ "**/android/**", "**/generated/**", "**/rust/target/**", - "**/mobile/ios/**" + "**/mobile/ios/**", + "**/eslint.config.js" ], "threshold": 0, "minTokens": 50, diff --git a/.vscode/settings.json b/.vscode/settings.json index 02222b2..30d456b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,7 +28,8 @@ "**/mnt/**", "**/dist/**", "**/node_modules/**", - "**/android/**" + "**/android/**", + "**/eslint.config.js" ], "[astro]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/apps/mobile/eslint.config.js b/apps/mobile/eslint.config.js index cc0d34f..49789d5 100644 --- a/apps/mobile/eslint.config.js +++ b/apps/mobile/eslint.config.js @@ -37,12 +37,11 @@ export default defineConfig([ // Expo (strip conflicting plugins defined elsewhere) ...expoConfig.map((c) => stripPlugins(c, ['@typescript-eslint'])), // Epic (strip conflicting plugins defined elsewhere) - ...epicConfig.map((c) => stripPlugins(c, ['import', '@typescript-eslint'])), + ...epicConfig.map((c) => stripPlugins(c, ['import'])), // ts-eslint eslint.configs.recommended, - ...tseslint.configs.strictTypeChecked, - ...tseslint.configs.stylisticTypeChecked, + { languageOptions: { parserOptions: { diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx index e5ffb4d..3f1486b 100644 --- a/apps/mobile/src/app/(tabs)/shell/detail.tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -72,6 +72,15 @@ function ShellDetail() { const connection = sess?.connection; const shell = sess?.shell; + // If the shell disconnects, leave this screen to the list view + useEffect(() => { + if (!sess) return; + if (sess.status === 'disconnected') { + // Replace so the detail screen isn't on the stack anymore + router.replace('/shell'); + } + }, [router, sess]); + // SSH -> xterm: on initialized, replay ring head then attach live listener useEffect(() => { const xterm = xtermRef.current; @@ -153,13 +162,17 @@ function ShellDetail() { textZoom={100} allowsLinkPreview={false} textInteractionEnabled={false} - // xterm-ish props (applied via setOptions inside the page) - fontFamily="Menlo, ui-monospace, monospace" - fontSize={18} // bump if it still feels small - cursorBlink - scrollback={10000} - themeBackground={theme.colors.background} - themeForeground={theme.colors.textPrimary} + // xterm options + options={{ + fontFamily: 'Menlo, ui-monospace, monospace', + fontSize: 18, + cursorBlink: true, + scrollback: 10000, + theme: { + background: theme.colors.background, + foreground: theme.colors.textPrimary, + }, + }} onRenderProcessGone={() => { console.log('WebView render process gone -> clear()'); const xr = xtermRef.current; diff --git a/apps/mobile/src/components/form-components.tsx b/apps/mobile/src/components/form-components.tsx index 391409c..284942c 100644 --- a/apps/mobile/src/components/form-components.tsx +++ b/apps/mobile/src/components/form-components.tsx @@ -3,6 +3,7 @@ import { createFormHookContexts, useStore, } from '@tanstack/react-form'; +import React from 'react'; import { Pressable, Switch, diff --git a/packages/react-native-xtermjs-webview/eslint.config.js b/packages/react-native-xtermjs-webview/eslint.config.js index a1e9c7c..cf86b2e 100644 --- a/packages/react-native-xtermjs-webview/eslint.config.js +++ b/packages/react-native-xtermjs-webview/eslint.config.js @@ -1,20 +1,20 @@ -import js from '@eslint/js'; +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 * as tsParser from '@typescript-eslint/parser'; +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 reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import tseslint from 'typescript-eslint'; -import { globalIgnores, defineConfig } from 'eslint/config'; export default defineConfig([ - globalIgnores(['dist']), + ...epicConfig, + + // ts-eslint + eslint.configs.recommended, { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs['recommended-latest'], - reactRefresh.configs.vite, - ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, @@ -24,4 +24,63 @@ export default defineConfig([ }, }, }, + + // @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', + '@typescript-eslint/consistent-type-imports': 'off', // we need this to avoid including xtermjs in the RN bundle + }, + }, ]); diff --git a/packages/react-native-xtermjs-webview/package.json b/packages/react-native-xtermjs-webview/package.json index 9eff657..4e2a036 100644 --- a/packages/react-native-xtermjs-webview/package.json +++ b/packages/react-native-xtermjs-webview/package.json @@ -7,7 +7,7 @@ ".": "./dist/index.js" }, "scripts": { - "dev": "vite", + "dev": "vite --config vite.config.internal.ts", "build:main": "tsc -b && vite build", "build:internal": "tsc -b && vite build --config vite.config.internal.ts", "fmt:check": "cross-env SORT_IMPORTS=true prettier --check .", @@ -35,9 +35,13 @@ "@xterm/xterm": "^5.5.0", "js-base64": "^3.7.8", "eslint": "^9.35.0", + "@eslint-community/eslint-plugin-eslint-comments": "^4.5.0", "eslint-plugin-react-hooks": "^5.2.0", + "@eslint-react/eslint-plugin": "^1.53.0", "vite-plugin-singlefile": "^2.3.0", "eslint-plugin-react-refresh": "^0.4.20", + "@typescript-eslint/parser": "^8.44.0", + "@typescript-eslint/utils": "^8.43.0", "globals": "^16.4.0", "prettier": "^3.6.2", "prettier-plugin-organize-imports": "^4.2.0", diff --git a/packages/react-native-xtermjs-webview/src-internal/main.tsx b/packages/react-native-xtermjs-webview/src-internal/main.tsx index 7b0b51e..a9543b0 100644 --- a/packages/react-native-xtermjs-webview/src-internal/main.tsx +++ b/packages/react-native-xtermjs-webview/src-internal/main.tsx @@ -1,7 +1,11 @@ -import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; +import { Terminal, type ITerminalOptions, type ITheme } from '@xterm/xterm'; import { Base64 } from 'js-base64'; import '@xterm/xterm/css/xterm.css'; +import { + type BridgeInboundMessage, + type BridgeOutboundMessage, +} from '../src/bridge'; declare global { interface Window { @@ -17,7 +21,7 @@ declare global { /** * Post typed messages to React Native */ -const post = (msg: unknown) => +const post = (msg: BridgeInboundMessage) => window.ReactNativeWebView?.postMessage?.(JSON.stringify(msg)); /** @@ -71,32 +75,17 @@ if (window.__FRESSH_XTERM_BRIDGE__) { // RN -> WebView handler (write, resize, setFont, setTheme, setOptions, clear, focus) const handler = (e: MessageEvent) => { try { - const msg = JSON.parse(e.data) as - | { type: 'write'; b64?: string; chunks?: string[] } - | { type: 'resize'; cols?: number; rows?: number } - | { type: 'setFont'; family?: string; size?: number } - | { type: 'setTheme'; background?: string; foreground?: string } - | { - type: 'setOptions'; - opts: Partial<{ - cursorBlink: boolean; - scrollback: number; - fontFamily: string; - fontSize: number; - }>; - } - | { type: 'clear' } - | { type: 'focus' }; + const msg = JSON.parse(e.data) as BridgeOutboundMessage; if (!msg || typeof msg.type !== 'string') return; switch (msg.type) { case 'write': { - if (typeof msg.b64 === 'string') { + if ('b64' in msg) { const bytes = Base64.toUint8Array(msg.b64); term.write(bytes); post({ type: 'debug', message: `write(bytes=${bytes.length})` }); - } else if (Array.isArray(msg.chunks)) { + } else if ('chunks' in msg && Array.isArray(msg.chunks)) { for (const b64 of msg.chunks) { const bytes = Base64.toUint8Array(b64); term.write(bytes); @@ -120,7 +109,7 @@ if (window.__FRESSH_XTERM_BRIDGE__) { case 'setFont': { const { family, size } = msg; - const patch: Partial = {}; + const patch: Partial = {}; if (family) patch.fontFamily = family; if (typeof size === 'number') patch.fontSize = size; if (Object.keys(patch).length) { @@ -136,7 +125,7 @@ if (window.__FRESSH_XTERM_BRIDGE__) { case 'setTheme': { const { background, foreground } = msg; - const theme: Partial = {}; + const theme: Partial = {}; if (background) { theme.background = background; document.body.style.backgroundColor = background; @@ -153,20 +142,44 @@ if (window.__FRESSH_XTERM_BRIDGE__) { } case 'setOptions': { - const opts = msg.opts ?? {}; - const { cursorBlink, scrollback, fontFamily, fontSize } = opts; - const patch: Partial = {}; - if (typeof cursorBlink === 'boolean') patch.cursorBlink = cursorBlink; - if (typeof scrollback === 'number') patch.scrollback = scrollback; - if (fontFamily) patch.fontFamily = fontFamily; - if (typeof fontSize === 'number') patch.fontSize = fontSize; + const incoming = (msg.opts ?? {}) as Record; + type PatchRecord = Partial< + Record< + keyof ITerminalOptions, + ITerminalOptions[keyof ITerminalOptions] + > + >; + const patch: PatchRecord = {}; + for (const [k, v] of Object.entries(incoming)) { + // Avoid touching cols/rows via options setters here + if (k === 'cols' || k === 'rows') continue; + // Theme: also mirror background to page for seamless visuals + if (k === 'theme' && v && typeof v === 'object') { + const theme = v as ITheme; + if (theme.background) { + document.body.style.backgroundColor = theme.background; + } + patch.theme = theme; + continue; + } + const key = k as keyof ITerminalOptions; + patch[key] = v as ITerminalOptions[keyof ITerminalOptions]; + } if (Object.keys(patch).length) { term.options = patch; post({ type: 'debug', message: `setOptions(${Object.keys(patch).join(',')})`, }); - if (patch.fontFamily || patch.fontSize) fitAddon.fit(); + // If dimensions-affecting options changed, refit + if ( + patch.fontFamily !== undefined || + patch.fontSize !== undefined || + patch.letterSpacing !== undefined || + patch.lineHeight !== undefined + ) { + fitAddon.fit(); + } } break; } diff --git a/packages/react-native-xtermjs-webview/src/bridge.ts b/packages/react-native-xtermjs-webview/src/bridge.ts new file mode 100644 index 0000000..fe0af9d --- /dev/null +++ b/packages/react-native-xtermjs-webview/src/bridge.ts @@ -0,0 +1,25 @@ +type ITerminalOptions = import('@xterm/xterm').ITerminalOptions; + +// Messages posted from the WebView (xterm page) to React Native +export type BridgeInboundMessage = + | { type: 'initialized' } + | { type: 'input'; b64: string } + | { type: 'debug'; message: string }; + +// Messages injected from React Native into the WebView (xterm page) +export type BridgeOutboundMessage = + | { type: 'write'; b64: string } + | { type: 'write'; chunks: string[] } + | { type: 'resize'; cols?: number; rows?: number } + | { type: 'setFont'; family?: string; size?: number } + | { type: 'setTheme'; background?: string; foreground?: string } + | { type: 'setOptions'; opts: Partial } + | { type: 'clear' } + | { type: 'focus' }; + +export type TerminalOptionsPatch = BridgeOutboundMessage extends { + type: 'setOptions'; + opts: infer O; +} + ? O + : never; diff --git a/packages/react-native-xtermjs-webview/src/index.tsx b/packages/react-native-xtermjs-webview/src/index.tsx index dfa6b75..ca7a014 100644 --- a/packages/react-native-xtermjs-webview/src/index.tsx +++ b/packages/react-native-xtermjs-webview/src/index.tsx @@ -1,33 +1,31 @@ +type ITerminalOptions = import('@xterm/xterm').ITerminalOptions; +import { Base64 } from 'js-base64'; import React, { useEffect, useImperativeHandle, useRef } from 'react'; import { WebView } from 'react-native-webview'; import htmlString from '../dist-internal/index.html?raw'; -import { Base64 } from 'js-base64'; +import { + type BridgeInboundMessage, + type BridgeOutboundMessage, + type TerminalOptionsPatch, +} from './bridge'; +// Re-exported shared types live in src/bridge.ts for library build +// Internal page imports the same file via ../src/bridge type StrictOmit = Omit; -type InboundMessage = - | { type: 'initialized' } - | { type: 'input'; b64: string } // user typed data from xterm -> RN - | { type: 'debug'; message: string }; +/** + * Message from the webview to RN + */ +type InboundMessage = BridgeInboundMessage; -type OutboundMessage = - | { type: 'write'; b64: string } - | { type: 'write'; chunks: string[] } - | { type: 'resize'; cols?: number; rows?: number } - | { type: 'setFont'; family?: string; size?: number } - | { type: 'setTheme'; background?: string; foreground?: string } - | { - type: 'setOptions'; - opts: Partial<{ - cursorBlink: boolean; - scrollback: number; - fontFamily: string; - fontSize: number; - }>; - } - | { type: 'clear' } - | { type: 'focus' }; +/** + * Message from RN to the webview + */ +type OutboundMessage = BridgeOutboundMessage; +/** + * Message from this pkg to calling RN + */ export type XtermInbound = | { type: 'initialized' } | { type: 'data'; data: Uint8Array } @@ -41,11 +39,7 @@ export type XtermWebViewHandle = { resize: (cols?: number, rows?: number) => void; setFont: (family?: string, size?: number) => void; setTheme: (background?: string, foreground?: string) => void; - setOptions: ( - opts: OutboundMessage extends { type: 'setOptions'; opts: infer O } - ? O - : never, - ) => void; + setOptions: (opts: TerminalOptionsPatch) => void; clear: () => void; focus: () => void; }; @@ -58,24 +52,14 @@ export interface XtermJsWebViewProps ref: React.RefObject; onMessage?: (msg: XtermInbound) => void; - // xterm-ish props - fontFamily?: string; - fontSize?: number; - cursorBlink?: boolean; - scrollback?: number; - themeBackground?: string; - themeForeground?: string; + // xterm Terminal.setOptions props (typed from @xterm/xterm) + options?: Partial; } export function XtermJsWebView({ ref, onMessage, - fontFamily, - fontSize, - cursorBlink, - scrollback, - themeBackground, - themeForeground, + options, ...props }: XtermJsWebViewProps) { const webRef = useRef(null); @@ -161,25 +145,39 @@ export function XtermJsWebView({ }; }, []); - // Push initial options/theme whenever props change + // Apply options changes via setOptions without remounting + const prevOptsRef = useRef | null>(null); useEffect(() => { - const opts: Record = {}; - if (typeof cursorBlink === 'boolean') opts.cursorBlink = cursorBlink; - if (typeof scrollback === 'number') opts.scrollback = scrollback; - if (fontFamily) opts.fontFamily = fontFamily; - if (typeof fontSize === 'number') opts.fontSize = fontSize; - if (Object.keys(opts).length) send({ type: 'setOptions', opts }); - }, [cursorBlink, scrollback, fontFamily, fontSize]); + const merged: Partial = { + ...(options ?? {}), + }; - useEffect(() => { - if (themeBackground || themeForeground) { - send({ - type: 'setTheme', - background: themeBackground, - foreground: themeForeground, - }); + // Compute shallow patch of changed keys to reduce noise + const prev: Partial = (prevOptsRef.current ?? + {}) as Partial; + type PatchRecord = Partial< + Record + >; + const patch: PatchRecord = {}; + const keys = new Set([ + ...Object.keys(prev as object), + ...Object.keys(merged as object), + ]); + let changed = false; + for (const k of keys) { + const key = k as keyof ITerminalOptions; + const prevVal = prev[key]; + const nextVal = merged[key]; + if (prevVal !== nextVal) { + patch[key] = nextVal as ITerminalOptions[keyof ITerminalOptions]; + changed = true; + } } - }, [themeBackground, themeForeground]); + if (changed) { + send({ type: 'setOptions', opts: patch }); + prevOptsRef.current = merged; + } + }, [options]); return ( { - // fs.writeFileSync('dep.log', `${dep}\n`, { flag: 'a' }); - // return false; - // } + // Externalize all non-relative, non-absolute imports (i.e. dependencies) + // Keep only our own sources and the raw internal HTML in the bundle. + external: (id) => { + if (logExternal) fs.writeFileSync('dep.log', `${id}\n`, { flag: 'a' }); + const isRelative = id.startsWith('.') || id.startsWith('/'); + const isInternalHtml = id.includes('dist-internal/index.html?raw'); + return !isRelative && !isInternalHtml; + }, }, lib: { entry: resolve(__dirname, 'src/index.tsx'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12e2a77..ec2a466 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -367,6 +367,12 @@ importers: '@epic-web/config': specifier: ^1.21.3 version: 1.21.3(@typescript-eslint/utils@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.35.0(jiti@2.5.1))(prettier-plugin-astro@0.14.1)(prettier-plugin-organize-imports@4.2.0(prettier@3.6.2)(typescript@5.9.2))(prettier@3.6.2)(typescript@5.9.2) + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.5.0 + version: 4.5.0(eslint@9.35.0(jiti@2.5.1)) + '@eslint-react/eslint-plugin': + specifier: ^1.53.0 + version: 1.53.1(eslint@9.35.0(jiti@2.5.1))(ts-api-utils@2.1.0(typescript@5.9.2))(typescript@5.9.2) '@eslint/js': specifier: ^9.35.0 version: 9.35.0 @@ -376,6 +382,12 @@ importers: '@types/react-dom': specifier: ^19.1.7 version: 19.1.9(@types/react@19.1.12) + '@typescript-eslint/parser': + specifier: ^8.44.0 + version: 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + '@typescript-eslint/utils': + specifier: ^8.43.0 + version: 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) '@vitejs/plugin-react': specifier: ^5.0.2 version: 5.0.2(vite@6.3.6(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))