diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index fbf7f45c9c1..b855a5d717c 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -32,6 +32,7 @@ on: - '.github/workflows/mobile.yml' - 'config/mobile-ci.vitest.config.mjs' - 'config/scripts/run-mobile-ci-tests.mjs' + - 'config/oxlint-code-quality-native-plugins.json' - 'config/.bun-version' - '.github/actions/install-node-dependencies/**' - '.github/actions/restore-pnpm-verification/**' @@ -154,6 +155,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Why here: pr.yml runs these import rules before mobile's install, when mobile's tsconfig + # (which extends expo's) can't load, so its import cycles were never seen. Zero tolerance. + - name: Lint mobile imports with mobile dependencies installed + working-directory: . + run: node node_modules/oxlint/bin/oxlint --config config/oxlint-code-quality-native-plugins.json mobile --deny-warnings + # Call installed tools so pnpm's dependency refresh cannot race between checks. - name: Typecheck id: production-types diff --git a/config/e2e-typecheck-mobile-native-stub.ts b/config/e2e-typecheck-mobile-native-stub.ts new file mode 100644 index 00000000000..c0cddc285b8 --- /dev/null +++ b/config/e2e-typecheck-mobile-native-stub.ts @@ -0,0 +1,2 @@ +// Stands in for mobile's native-only packages in config/tsconfig.e2e.json (see its `paths`). +export {} diff --git a/config/scripts/mobile-release-check-scope.test.mjs b/config/scripts/mobile-release-check-scope.test.mjs index 28b1aaf92fe..7195be14d45 100644 --- a/config/scripts/mobile-release-check-scope.test.mjs +++ b/config/scripts/mobile-release-check-scope.test.mjs @@ -96,7 +96,13 @@ it('gates Ruby independently and retains mobile static validation', () => { for (const step of gated) { expect(step.if).toBe("steps.ruby-scope.outputs.should_run != 'false'") } - for (const name of ['Typecheck', 'Typecheck tests (ratchet)', 'Lint', 'Check formatting']) { + for (const name of [ + 'Lint mobile imports with mobile dependencies installed', + 'Typecheck', + 'Typecheck tests (ratchet)', + 'Lint', + 'Check formatting' + ]) { expect(steps.find((step) => step.name === name)?.if).toBeUndefined() expect(steps.some((step) => step.name === name)).toBe(true) } diff --git a/config/scripts/mobile-web-app-browser-pane-render.test.mjs b/config/scripts/mobile-web-app-browser-pane-render.test.mjs index e30b16a7dd4..1ae4d7e7a74 100644 --- a/config/scripts/mobile-web-app-browser-pane-render.test.mjs +++ b/config/scripts/mobile-web-app-browser-pane-render.test.mjs @@ -82,7 +82,7 @@ const OVER_CAP_FRAME = { width: 2400, height: 2160 } * because the capability probe is the session screen's to make and C7's to prove. */ const ROUTE_SOURCE = ` -import { useHostClient } from '../../../src/transport/client-context' +import { useHostClient } from '../../../src/transport/host-client-hooks' import { MobileBrowserPane } from '../../../src/browser/MobileBrowserPane' const TAB = { diff --git a/config/tsconfig.e2e.json b/config/tsconfig.e2e.json index cfad2852b92..e6ef77eb68e 100644 --- a/config/tsconfig.e2e.json +++ b/config/tsconfig.e2e.json @@ -20,7 +20,12 @@ "types": ["node"], "paths": { "@renderer/*": ["../src/renderer/src/*"], - "@/*": ["../src/renderer/src/*"] + "@/*": ["../src/renderer/src/*"], + // Why: specs reach mobile/src/transport, whose native imports resolve only once mobile is + // installed and then load React Native's globals (numeric timers, a required NODE_ENV). + // An empty stub keeps the error count the same with or without mobile's install. + "expo-crypto": ["./e2e-typecheck-mobile-native-stub.ts"], + "@react-native-async-storage/async-storage": ["./e2e-typecheck-mobile-native-stub.ts"] } } } diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx index f7a1e80c231..ef2fe3840ca 100644 --- a/mobile/app/connection-log.tsx +++ b/mobile/app/connection-log.tsx @@ -4,7 +4,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router' import * as Clipboard from 'expo-clipboard' import { loadHosts } from '../src/transport/host-store' import { connectionLogStore } from '../src/transport/persisted-connection-log-store' -import { useHostClient, useRpcClientContext } from '../src/transport/client-context' +import { useRpcClientContext } from '../src/transport/client-context' +import { useHostClient } from '../src/transport/host-client-hooks' import { useConnectionPathStatus, useReconnectAttempt diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx index 90e9bc6c167..acd631549f4 100644 --- a/mobile/app/h/[hostId]/edit.tsx +++ b/mobile/app/h/[hostId]/edit.tsx @@ -17,7 +17,7 @@ import { colors, radii, spacing, typography } from '../../../src/theme/mobile-th import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store' import { displayHostEndpoint } from '../../../src/transport/host-endpoint' import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit' -import { usePrimeHosts, useRefreshHostClient } from '../../../src/transport/client-context' +import { usePrimeHosts, useRefreshHostClient } from '../../../src/transport/host-client-hooks' import type { HostProfile } from '../../../src/transport/types' import { hostOs } from '../../../src/platform/host-os' diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index be790427088..d700ea83f42 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -9,7 +9,7 @@ import { type PreProfilePairingAttempt } from '../src/transport/pre-profile-pairing-coordinator' import type { ConnectionLogEntry } from '../src/transport/types' -import { useRefreshHostClient } from '../src/transport/client-context' +import { useRefreshHostClient } from '../src/transport/host-client-hooks' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' import { diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 61e99f19f2a..5b4c17b2501 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -17,7 +17,7 @@ import { type PreProfilePairingAttempt } from '../src/transport/pre-profile-pairing-coordinator' import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types' -import { useRefreshHostClient } from '../src/transport/client-context' +import { useRefreshHostClient } from '../src/transport/host-client-hooks' import { colors, spacing } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' diff --git a/mobile/src/accounts/MobileAccountsScreen.tsx b/mobile/src/accounts/MobileAccountsScreen.tsx index 1df0d0dd74a..3f2c74a865b 100644 --- a/mobile/src/accounts/MobileAccountsScreen.tsx +++ b/mobile/src/accounts/MobileAccountsScreen.tsx @@ -4,7 +4,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams } from 'expo-router' import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native' import { loadHosts } from '../transport/host-store' -import { useHostClient } from '../transport/client-context' +import { useHostClient } from '../transport/host-client-hooks' import { colors, spacing } from '../theme/mobile-theme' import { styles } from './mobile-accounts-screen-styles' import { useNow } from '../hooks/use-now' diff --git a/mobile/src/accounts/mobile-accounts-screen-reset-credit.test.ts b/mobile/src/accounts/mobile-accounts-screen-reset-credit.test.ts index 2cbbb62e6ab..d960f7a081b 100644 --- a/mobile/src/accounts/mobile-accounts-screen-reset-credit.test.ts +++ b/mobile/src/accounts/mobile-accounts-screen-reset-credit.test.ts @@ -60,7 +60,7 @@ vi.mock('lucide-react-native', () => ({ vi.mock('../transport/host-store', () => ({ loadHosts: dependencies.loadHosts })) -vi.mock('../transport/client-context', () => { +vi.mock('../transport/host-client-hooks', () => { const client = { sendRequest: async (method: string, params?: unknown, options?: unknown) => { if (method === 'status.get') { diff --git a/mobile/src/agent-history/agent-history-current-host-capability.test.tsx b/mobile/src/agent-history/agent-history-current-host-capability.test.tsx index 51d4a91f955..c35fa7af497 100644 --- a/mobile/src/agent-history/agent-history-current-host-capability.test.tsx +++ b/mobile/src/agent-history/agent-history-current-host-capability.test.tsx @@ -10,7 +10,7 @@ const connection = vi.hoisted((): { client: RpcClient | null; state: ConnectionS client: null, state: 'connected' })) -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => connection, useForceReconnect: () => vi.fn() })) diff --git a/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx b/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx index ecfdd4bae9c..f9fbd5c7f67 100644 --- a/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx +++ b/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx @@ -51,7 +51,7 @@ vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }) vi.mock('../navigation/route-handoff', () => ({ useRouteHandoff: () => ({ push: notifications.push, back: vi.fn(), canGoBack: () => false }) })) -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: state.client, state: state.connection }), useForceReconnect: () => vi.fn() })) diff --git a/mobile/src/agent-history/use-mobile-agent-history-state.ts b/mobile/src/agent-history/use-mobile-agent-history-state.ts index 00bd377990a..1973ecf631e 100644 --- a/mobile/src/agent-history/use-mobile-agent-history-state.ts +++ b/mobile/src/agent-history/use-mobile-agent-history-state.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useForceReconnect } from '../transport/client-context' +import { useForceReconnect } from '../transport/host-client-hooks' import { useWorkspaceClient } from '../transport/use-workspace-client' import type { RpcClient } from '../transport/rpc-client' import { connectionRetryAction } from '../transport/connection-retry-action' diff --git a/mobile/src/components/HostProtocolGate.test.ts b/mobile/src/components/HostProtocolGate.test.ts index 12dfa6e72ed..0e0220694ee 100644 --- a/mobile/src/components/HostProtocolGate.test.ts +++ b/mobile/src/components/HostProtocolGate.test.ts @@ -35,7 +35,7 @@ vi.mock('expo-router', () => ({ const hostClient = vi.hoisted(() => ({ current: { client: null as RpcClient | null, state: 'disconnected' as string } })) -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => hostClient.current })) // The wall's release offer: the native hook imports the update checker module. diff --git a/mobile/src/components/HostProtocolGate.tsx b/mobile/src/components/HostProtocolGate.tsx index 3202530c17d..64f06523286 100644 --- a/mobile/src/components/HostProtocolGate.tsx +++ b/mobile/src/components/HostProtocolGate.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, type ReactNode } from 'react' import { ActivityIndicator, StyleSheet, View } from 'react-native' -import { useHostClient } from '../transport/client-context' +import { useHostClient } from '../transport/host-client-hooks' import { useHostStatusGates } from '../transport/host-status-gates' import { colors } from '../theme/mobile-theme' import { HostStatusGatesContext } from './host-protocol-gates-context' diff --git a/mobile/src/diagnostics/use-mobile-web-bundle-probe.ts b/mobile/src/diagnostics/use-mobile-web-bundle-probe.ts index aeebb4573a5..6ae2487208c 100644 --- a/mobile/src/diagnostics/use-mobile-web-bundle-probe.ts +++ b/mobile/src/diagnostics/use-mobile-web-bundle-probe.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { useHostClient } from '../transport/client-context' +import { useHostClient } from '../transport/host-client-hooks' import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch' import { readMobileWebBundleErrorCode } from '../transport/mobile-web-bundle-operations' import { startDiagnosticFetchTimeout } from './diagnostic-fetch-timeout' diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts index 8033464ffa4..47db7d64656 100644 --- a/mobile/src/files/MobileFileExplorerPanel.test.ts +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -81,7 +81,7 @@ vi.mock('../platform/haptics', () => ({ triggerSelection: vi.fn() })) -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useForceReconnect: () => mockTransport.forceReconnect, useHostClient: () => ({ client: mockTransport.client, diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index c8d22755fe9..a6078ec7510 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -10,7 +10,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context' import { ChevronLeft, X } from 'lucide-react-native' import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff' -import { useForceReconnect } from '../transport/client-context' +import { useForceReconnect } from '../transport/host-client-hooks' import { useWorkspaceClient } from '../transport/use-workspace-client' import { connectionRetryAction } from '../transport/connection-retry-action' import { getWorktreeLabel } from '../session/worktree-label' diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index 973751491a2..afc490abcb3 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -5,7 +5,7 @@ import { ChevronLeft, Save } from 'lucide-react-native' import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff' import { getWorktreeLabel } from '../session/worktree-label' import { colors, spacing } from '../theme/mobile-theme' -import { useForceReconnect } from '../transport/client-context' +import { useForceReconnect } from '../transport/host-client-hooks' import { useWorkspaceClient } from '../transport/use-workspace-client' import { connectionRetryAction } from '../transport/connection-retry-action' import { diff --git a/mobile/src/files/mobile-file-preview-retry.test.tsx b/mobile/src/files/mobile-file-preview-retry.test.tsx index 54d626d8283..7db1218ae8f 100644 --- a/mobile/src/files/mobile-file-preview-retry.test.tsx +++ b/mobile/src/files/mobile-file-preview-retry.test.tsx @@ -38,7 +38,7 @@ vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: () => null })) vi.mock('./MobileFileMarkdownPreview', () => ({ MobileFileMarkdownPreview: () => null })) vi.mock('./MobileFilePreviewSourceText', () => ({ MobileFilePreviewSourceText: () => null })) // A host the shell has not reached by default: no client, so the screen settles on `waiting`. -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useForceReconnect: () => transport.forceReconnect, useHostClient: () => ({ ...transport.connection, clientId: null }) })) diff --git a/mobile/src/home/MobileHomeScreen.tsx b/mobile/src/home/MobileHomeScreen.tsx index 7bf9089f77e..b36835614a8 100644 --- a/mobile/src/home/MobileHomeScreen.tsx +++ b/mobile/src/home/MobileHomeScreen.tsx @@ -17,7 +17,7 @@ import { useDisconnectHostClient, useForceReconnect, useForgetHostClient -} from '../transport/client-context' +} from '../transport/host-client-hooks' import { hostEndpointLabel } from '../transport/host-endpoint-label' import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect' import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle' diff --git a/mobile/src/home/use-mobile-home-host-connections.ts b/mobile/src/home/use-mobile-home-host-connections.ts index 989583f11ab..cbd77919d0c 100644 --- a/mobile/src/home/use-mobile-home-host-connections.ts +++ b/mobile/src/home/use-mobile-home-host-connections.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { decodeAccountsSnapshot } from '../components/AccountUsage' import { subscribeToDesktopNotifications } from '../notifications/mobile-notifications' -import { usePrimeHosts } from '../transport/client-context' +import { usePrimeHosts } from '../transport/host-client-hooks' import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate' import { selectHomeAutoConnectHostIds } from '../transport/home-host-auto-connect' import type { RpcClient } from '../transport/rpc-client' diff --git a/mobile/src/host-edit-route-accessibility.test.ts b/mobile/src/host-edit-route-accessibility.test.ts index 12d4ae230e1..3526db9b1b4 100644 --- a/mobile/src/host-edit-route-accessibility.test.ts +++ b/mobile/src/host-edit-route-accessibility.test.ts @@ -41,7 +41,7 @@ vi.mock('./transport/host-store', () => ({ updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint })) -vi.mock('./transport/client-context', () => ({ +vi.mock('./transport/host-client-hooks', () => ({ usePrimeHosts: () => dependencies.primeHosts, useRefreshHostClient: () => dependencies.refreshHostClient })) diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts index 0c55da0cb0f..4ca9a356116 100644 --- a/mobile/src/host-edit-save-flow.test.ts +++ b/mobile/src/host-edit-save-flow.test.ts @@ -42,7 +42,7 @@ vi.mock('./transport/host-store', () => ({ updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint })) -vi.mock('./transport/client-context', () => ({ +vi.mock('./transport/host-client-hooks', () => ({ usePrimeHosts: () => dependencies.primeHosts, useRefreshHostClient: () => dependencies.refreshHostClient })) diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index d2661f09668..e79cf96c148 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -8,7 +8,11 @@ import { resolveHostRouteActionState } from '../host-route-action-state' import { useActiveWorktreeScroll } from '../hooks/use-active-worktree-scroll' import { useNow } from '../hooks/use-now' import { useResponsiveLayout } from '../layout/responsive-layout' -import { useForgetHostClient, useForceReconnect, useHostClient } from '../transport/client-context' +import { + useForgetHostClient, + useForceReconnect, + useHostClient +} from '../transport/host-client-hooks' import { useLastConnectedAt, useReconnectAttempt, diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index d2ae2b906e1..66b4b2ce6c2 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import type { useRouter } from 'expo-router' import { floatingWorkspaceSessionPath } from '../session/floating-workspace' import { savePinnedIds } from '../storage/preferences' -import type { useForgetHostClient } from '../transport/client-context' +import type { useForgetHostClient } from '../transport/host-client-hooks' import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle' import { isPageHostRemovalUnavailable } from '../transport/page-host-removal-refusal' import type { RpcClient } from '../transport/rpc-client' diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index 1b7976259ed..e13ed72038d 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -20,7 +20,7 @@ vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context' vi.mock('expo-router', mocks['expo-router']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) -vi.mock('../transport/client-context', mocks['../transport/client-context']) +vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session']) diff --git a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx index 7dbae2341b8..fa42ad9b426 100644 --- a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx +++ b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx @@ -117,7 +117,7 @@ vi.mock('../../modules/orca-mobile-web-shell/src', async () => { vi.mock('../app-update/use-wall-app-update', () => ({ useWallAppUpdate: () => null })) -vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null }) })) +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: null }) })) vi.mock('./use-page-host-snapshot', () => ({ usePageHostSnapshot: () => ({ snapshot: SNAPSHOT, diff --git a/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx b/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx index 964a06d88c5..9ffbf5892e9 100644 --- a/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx +++ b/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx @@ -57,7 +57,7 @@ vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({ vi.mock('../components/host-protocol-gates-context', () => ({ useHostProtocolGates: () => doubles.gates })) -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: {}, state: 'connected' }) })) vi.mock('../transport/rpc-operation', () => ({ diff --git a/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx b/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx index eddf863c588..def7f3e679e 100644 --- a/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx +++ b/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx @@ -36,7 +36,7 @@ vi.mock('lucide-react-native', mocks['lucide-react-native']) vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) -vi.mock('../transport/client-context', mocks['../transport/client-context']) +vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session']) diff --git a/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx b/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx index ef3211e5889..08f74e23976 100644 --- a/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx +++ b/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx @@ -75,7 +75,7 @@ vi.mock('lucide-react-native', mocks['lucide-react-native']) vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) -vi.mock('../transport/client-context', mocks['../transport/client-context']) +vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) // The screen's session is the reducer's, as `sessionFor` carried it. vi.mock('./use-mobile-web-shell-session', () => ({ diff --git a/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx b/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx index ab3b64de09e..23996e97777 100644 --- a/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx +++ b/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx @@ -53,7 +53,7 @@ vi.mock('lucide-react-native', mocks['lucide-react-native']) vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) -vi.mock('../transport/client-context', mocks['../transport/client-context']) +vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) // Each host route's session as the reducer leaves it under a declaring page on a wide layout. vi.mock('./use-mobile-web-shell-session', () => ({ diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx index 9bfe6bafcb6..c5837839d26 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx @@ -28,7 +28,7 @@ vi.mock('react-native', () => ({ })) // The workspace route reads the desktop's connection to decide whether a server is reachable. -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) vi.mock('../transport/workspace-server', () => ({ diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-failed-update-fallback.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-failed-update-fallback.test.ts index 3b473e1bfa1..afeeac0e2c6 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-failed-update-fallback.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-failed-update-fallback.test.ts @@ -54,7 +54,7 @@ vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({ vi.mock('../components/host-protocol-gates-context', () => ({ useHostProtocolGates: () => doubles.gates })) -vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection })) +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => doubles.connection })) vi.mock('../transport/rpc-operation', () => ({ defineRpcOperation: (definition: unknown) => definition, runRpcOperation: async () => { diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx index 0e74320be27..53644e78c51 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx @@ -28,7 +28,7 @@ vi.mock('react-native', () => ({ })) // The workspace route reads the desktop's connection to decide whether a server is reachable. -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) vi.mock('../transport/workspace-server', () => ({ diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx index 2303b0b9e47..917b4faaecc 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx @@ -20,7 +20,7 @@ vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context' vi.mock('expo-router', mocks['expo-router']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) -vi.mock('../transport/client-context', mocks['../transport/client-context']) +vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session']) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-offline-grant-staleness.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-offline-grant-staleness.test.ts index 627dbf0d129..db56073fe39 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-offline-grant-staleness.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-offline-grant-staleness.test.ts @@ -45,7 +45,7 @@ vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({ vi.mock('../components/host-protocol-gates-context', () => ({ useHostProtocolGates: () => doubles.gates })) -vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection })) +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => doubles.connection })) vi.mock('../transport/rpc-operation', () => ({ defineRpcOperation: (definition: unknown) => definition, runRpcOperation: async () => { diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-screen.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-screen.test.tsx index 678253ce146..71e970786c8 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-screen.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-screen.test.tsx @@ -20,7 +20,7 @@ vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context' vi.mock('expo-router', mocks['expo-router']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) -vi.mock('../transport/client-context', mocks['../transport/client-context']) +vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session']) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx index 2f62e2e01ef..0bcd3e95338 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx @@ -150,7 +150,7 @@ export function screenModuleMocks(dependencies: ScreenDependencies) { '../app-update/use-wall-app-update': () => ({ useWallAppUpdate: () => null }), // The real bridge hook runs, so the props it owns are the ones the view is handed here; only the // client lookup is stubbed, because reaching it imports the Expo runtime this test does not have. - '../transport/client-context': () => ({ + '../transport/host-client-hooks': () => ({ useHostClient: () => ({ client: dependencies.client }) }), // Reaching the real one imports the host store and expo-secure-store, whose module touches an Expo diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx index 4e5b38aa559..dea4f920d26 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx @@ -31,7 +31,7 @@ vi.mock('react-native', () => ({ })) // The workspace route reads the desktop's connection to decide whether a server is reachable. -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) vi.mock('../transport/workspace-server', () => ({ diff --git a/mobile/src/mobile-web-shell/shell-route-first-frame.test.tsx b/mobile/src/mobile-web-shell/shell-route-first-frame.test.tsx index 32e793cf18c..1f692e5ffe3 100644 --- a/mobile/src/mobile-web-shell/shell-route-first-frame.test.tsx +++ b/mobile/src/mobile-web-shell/shell-route-first-frame.test.tsx @@ -37,7 +37,7 @@ vi.mock('react-native', () => ({ })) // The workspace route reads the desktop's connection to decide whether a server is reachable. -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: null, state: 'disconnected' }) })) vi.mock('../transport/workspace-server', () => ({ diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts index 13876aa0d53..bc7453dd86e 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts @@ -30,7 +30,7 @@ import type { FakeRpcClient } from './bridge-host-test-fakes' const doubles = vi.hoisted((): { client: FakeRpcClient | null } => ({ client: null })) // Reaching the real one imports the Expo runtime this test does not have; the hook reads one field. -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: doubles.client }) })) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts index b640887706e..db216269345 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts @@ -3,7 +3,7 @@ import type { MobileWebShellBridgeMessagePayload, OrcaMobileWebShellViewHandle } from '../../modules/orca-mobile-web-shell/src' -import { useHostClient } from '../transport/client-context' +import { useHostClient } from '../transport/host-client-hooks' import { createBridgeDiagnosticReporter } from './bridge-diagnostic-log' import { bridgeDiagnosticLine, frameWithoutHostLine } from './shell-bridge-log-lines' import { recordShellLifecycleLine } from './shell-lifecycle-line-recorder' diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts index 2e915d7f9d2..46591c7ccec 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts @@ -77,7 +77,7 @@ vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({ vi.mock('../components/host-protocol-gates-context', () => ({ useHostProtocolGates: () => doubles.gates })) -vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection })) +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => doubles.connection })) vi.mock('../transport/rpc-operation', () => ({ defineRpcOperation: (definition: unknown) => definition, runRpcOperation: async (client: unknown) => { diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts index 05cfba5dd53..0d837ef0445 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' import { useHostProtocolGates } from '../components/host-protocol-gates-context' -import { useHostClient } from '../transport/client-context' +import { useHostClient } from '../transport/host-client-hooks' import type { GenerationStore } from './generation-store' import { deriveHostCacheKey } from './host-cache-key' import { download, openCache, readManifest } from './mobile-web-shell-session-effects' diff --git a/mobile/src/navigation/workspace-route.test.tsx b/mobile/src/navigation/workspace-route.test.tsx index 888571faf75..b29cc2c2bf4 100644 --- a/mobile/src/navigation/workspace-route.test.tsx +++ b/mobile/src/navigation/workspace-route.test.tsx @@ -36,7 +36,7 @@ vi.mock('react-native', () => ({ Text: 'Text', Pressable: 'Pressable' })) -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: route.client, state: 'connected' }) })) // The server's status is read for real; only the desktop's persisted records are stubbed. diff --git a/mobile/src/navigation/workspace-route.tsx b/mobile/src/navigation/workspace-route.tsx index 8453053b932..c4e22259c98 100644 --- a/mobile/src/navigation/workspace-route.tsx +++ b/mobile/src/navigation/workspace-route.tsx @@ -8,7 +8,7 @@ import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context' import { PageRouteUnavailableScreen } from '../mobile-web-shell/PageRouteUnavailableScreen' -import { useHostClient } from '../transport/client-context' +import { useHostClient } from '../transport/host-client-hooks' import { WorkspaceServerContext } from '../transport/use-workspace-client' import { useWorkspaceServer } from '../transport/workspace-server' diff --git a/mobile/src/session/MobileDiffReviewRouteScreen.tsx b/mobile/src/session/MobileDiffReviewRouteScreen.tsx index ddca209789c..a3c83a316af 100644 --- a/mobile/src/session/MobileDiffReviewRouteScreen.tsx +++ b/mobile/src/session/MobileDiffReviewRouteScreen.tsx @@ -4,7 +4,7 @@ import { MobileDiffReviewScreenView } from '../components/MobileDiffReviewScreen import { firstReviewParam, normalizeReviewFilterParam } from './mobile-diff-review-screen-model' import { normalizeReviewAreaParam } from './mobile-diff-review-positioning' import { useMobileDiffReviewController } from './use-mobile-diff-review-controller' -import { useForceReconnect } from '../transport/client-context' +import { useForceReconnect } from '../transport/host-client-hooks' import { useWorkspaceClient } from '../transport/use-workspace-client' import { useHostProtocolGates } from '../components/host-protocol-gates-context' import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff' diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts index 2b99af3e73b..287a12e958f 100644 --- a/mobile/src/session/use-mobile-session-foundation.ts +++ b/mobile/src/session/use-mobile-session-foundation.ts @@ -3,7 +3,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useLocalSearchParams } from 'expo-router' import { useRouteHandoff } from '../navigation/route-handoff' import { HOST_DOCK_MIN_WIDTH } from '../storage/preferences' -import { useForceReconnect } from '../transport/client-context' +import { useForceReconnect } from '../transport/host-client-hooks' import { useWorkspaceClient } from '../transport/use-workspace-client' import { useLastConnectedAt, diff --git a/mobile/src/source-control/MobileGitHistoryList.test.tsx b/mobile/src/source-control/MobileGitHistoryList.test.tsx index d8ef0c2438d..498d5ba1b34 100644 --- a/mobile/src/source-control/MobileGitHistoryList.test.tsx +++ b/mobile/src/source-control/MobileGitHistoryList.test.tsx @@ -31,7 +31,7 @@ const transport = vi.hoisted( forceReconnect: () => Promise.resolve() }) ) -vi.mock('../transport/client-context', () => ({ +vi.mock('../transport/host-client-hooks', () => ({ useForceReconnect: () => transport.forceReconnect })) diff --git a/mobile/src/source-control/MobileGitHistoryList.tsx b/mobile/src/source-control/MobileGitHistoryList.tsx index 495ff22d362..86ed5d0ae7d 100644 --- a/mobile/src/source-control/MobileGitHistoryList.tsx +++ b/mobile/src/source-control/MobileGitHistoryList.tsx @@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { ConnectionState } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' -import { useForceReconnect } from '../transport/client-context' +import { useForceReconnect } from '../transport/host-client-hooks' import { connectionRetryAction } from '../transport/connection-retry-action' import { gitCommitCompareRead } from './mobile-git-read-operations' import type { MobileGitChangedFile } from './git-compare-reply-schema' diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index 940954ca069..ebf7f14b9a3 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useForceReconnect } from '../transport/client-context' +import { useForceReconnect } from '../transport/host-client-hooks' import { useWorkspaceClient } from '../transport/use-workspace-client' import { getWorktreeLabel } from '../session/worktree-label' import { useMobileGitRequests } from './use-mobile-git-requests' diff --git a/mobile/src/tasks/mobile-tasks-dependencies.ts b/mobile/src/tasks/mobile-tasks-dependencies.ts index 2be9049c975..b317b650e0a 100644 --- a/mobile/src/tasks/mobile-tasks-dependencies.ts +++ b/mobile/src/tasks/mobile-tasks-dependencies.ts @@ -55,7 +55,7 @@ export { } from 'lucide-react-native' export type { RpcClient } from '../transport/rpc-client' export type { RpcSuccess } from '../transport/types' -export { useHostClient } from '../transport/client-context' +export { useHostClient } from '../transport/host-client-hooks' export { useLastConnectedAt, useRelayRecoveryStatus, diff --git a/mobile/src/terminal/document/cell-geometry.ts b/mobile/src/terminal/document/cell-geometry.ts index 79db5156026..d4c0157b21d 100644 --- a/mobile/src/terminal/document/cell-geometry.ts +++ b/mobile/src/terminal/document/cell-geometry.ts @@ -1,5 +1,4 @@ -import { getCellHeight } from './fit-scale' -import { getCellWidth, getTotalScale } from './viewport-transform' +import { getCellHeight, getCellWidth, getTotalScale } from './cell-metrics' import type { TerminalDocumentScope } from './document-scope' export function cellToViewportPx(scope: TerminalDocumentScope, col: number, absRow: number) { diff --git a/mobile/src/terminal/document/cell-metrics.ts b/mobile/src/terminal/document/cell-metrics.ts new file mode 100644 index 00000000000..723e81d4578 --- /dev/null +++ b/mobile/src/terminal/document/cell-metrics.ts @@ -0,0 +1,31 @@ +import type { TerminalDocumentScope } from './document-scope' + +// Why: a leaf of pure reads, so fit, transform and overlay modules can share them without a cycle. + +/** 0 until the renderer has measured a cell. */ +export function getMeasuredCellHeight(scope: TerminalDocumentScope) { + const core = scope.term?._core + if (core && core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 0 + } + return 0 +} + +export function getCellHeight(scope: TerminalDocumentScope) { + return getMeasuredCellHeight(scope) || 15 +} + +export function getCellWidth(scope: TerminalDocumentScope) { + if (!scope.term || !scope.term._core) { + return 0 + } + const core = scope.term._core + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.width || 0 + } + return 0 +} + +export function getTotalScale(scope: TerminalDocumentScope) { + return scope.currentScale * scope.userScale +} diff --git a/mobile/src/terminal/document/fit-scale.ts b/mobile/src/terminal/document/fit-scale.ts index 8047b9f630c..6b0c3cac0db 100644 --- a/mobile/src/terminal/document/fit-scale.ts +++ b/mobile/src/terminal/document/fit-scale.ts @@ -1,21 +1,11 @@ import { repositionOverlay } from './selection-overlay' -import { - computeFitScale, - flog, - getCellWidth, - getMeasuredCellHeight, - getTotalScale, - updateTransform -} from './viewport-transform' +import { computeFitScale, flog, updateTransform } from './viewport-transform' +import { getCellWidth, getTotalScale } from './cell-metrics' import type { TerminalDocumentScope } from './document-scope' import type { TerminalViewportChange } from './document-host-seams' import { scheduleDocumentFrame } from './document-frame-registry' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' -export function getCellHeight(scope: TerminalDocumentScope) { - return getMeasuredCellHeight(scope) || 15 -} - // Why: clamp pan so the terminal content always covers the viewport // when zoomed in. When content is smaller than viewport in a // dimension, pin to top-left (no floating in the middle). diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts index 3d5352b3962..6adfb3bcdee 100644 --- a/mobile/src/terminal/document/host-message-router.ts +++ b/mobile/src/terminal/document/host-message-router.ts @@ -6,7 +6,7 @@ import { emitModesIfChanged } from './mode-mirroring' import { reflow } from './reflow' import { resumeTerminalDataReplyAuthority } from './query-reply' import { repositionOverlay } from './selection-overlay' -import { cancelSelect } from './selection-range' +import { cancelSelect } from './selection-mode' import { resetEvictionCounter } from './selection-state-and-eviction' import { applyTerminalTheme } from './terminal-theme' import { init, resize, write } from './terminal-init' diff --git a/mobile/src/terminal/document/keyboard-avoidance-metrics.ts b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts index a0f28d801c1..f21f5ba489b 100644 --- a/mobile/src/terminal/document/keyboard-avoidance-metrics.ts +++ b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts @@ -1,4 +1,4 @@ -import { getMeasuredCellHeight, getTotalScale } from './viewport-transform' +import { getMeasuredCellHeight, getTotalScale } from './cell-metrics' import { notify } from './host-notify' import type { TerminalDocumentCell, diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts index dbae42668d4..53415f966ef 100644 --- a/mobile/src/terminal/document/mouse-click-drag.ts +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -1,5 +1,6 @@ import { handleDragMove, repositionOverlay, stopEdgeScroll } from './selection-overlay' -import { applyXtermSelection, cancelSelect } from './selection-range' +import { cancelSelect } from './selection-mode' +import { applyXtermSelection } from './selection-range' import { notify } from './host-notify' import { getMouseTrackingMode, isSafeSgrMouseCoordinate } from './mouse-input-encoding' import { viewportToCell } from './viewport-cell' diff --git a/mobile/src/terminal/document/mouse-report-cell.ts b/mobile/src/terminal/document/mouse-report-cell.ts index 58ee2ff80b7..b985128f34d 100644 --- a/mobile/src/terminal/document/mouse-report-cell.ts +++ b/mobile/src/terminal/document/mouse-report-cell.ts @@ -1,5 +1,4 @@ -import { getCellHeight } from './fit-scale' -import { getCellWidth, getTotalScale } from './viewport-transform' +import { getCellHeight, getCellWidth, getTotalScale } from './cell-metrics' import { viewportPoint } from './viewport-cell' import type { TerminalDocumentScope } from './document-scope' diff --git a/mobile/src/terminal/document/native-document-bundle.test.ts b/mobile/src/terminal/document/native-document-bundle.test.ts index 4389c3bb105..a946ed30d73 100644 --- a/mobile/src/terminal/document/native-document-bundle.test.ts +++ b/mobile/src/terminal/document/native-document-bundle.test.ts @@ -313,8 +313,8 @@ describe('the bundled native document', () => { expect(inputs.filter((input) => input.includes('node_modules'))).toEqual([]) // 50: `laid-out-cell-box` reads the cell box xterm laid out, for web-ready and each render; // `terminal-grid-fit` is the one fit of that box and `terminal-cell-box` its one comparison, - // both shared with the app. - expect(inputs).toHaveLength(50) + // both shared with the app. 52: `cell-metrics` and `selection-mode` split out to break cycles. + expect(inputs).toHaveLength(52) expect(script).not.toContain('__commonJS') // `__esm` wrappers are esbuild's answer to a cycle, and a cycle would make a module's top level // run at first import rather than where the bundle places it. diff --git a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts index 27c91d3031d..ac51924e747 100644 --- a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -1,5 +1,5 @@ -import { getCellHeight } from './fit-scale' -import { getTotalScale, updateScrollIndicator } from './viewport-transform' +import { getCellHeight, getTotalScale } from './cell-metrics' +import { updateScrollIndicator } from './viewport-transform' import type { TerminalDocumentScope } from './document-scope' import { scheduleDocumentFrame } from './document-frame-registry' diff --git a/mobile/src/terminal/document/selection-menu-buttons.ts b/mobile/src/terminal/document/selection-menu-buttons.ts index 6f7dc745907..150e6a99b42 100644 --- a/mobile/src/terminal/document/selection-menu-buttons.ts +++ b/mobile/src/terminal/document/selection-menu-buttons.ts @@ -1,6 +1,6 @@ import type { TerminalDocumentScope } from './document-scope' import { notify } from './host-notify' -import { cancelSelect } from './selection-range' +import { cancelSelect } from './selection-mode' import { repositionOverlay } from './selection-overlay' export function startSelectionMenuButtons(scope: TerminalDocumentScope) { diff --git a/mobile/src/terminal/document/selection-mode.ts b/mobile/src/terminal/document/selection-mode.ts new file mode 100644 index 00000000000..aa9c7c5033a --- /dev/null +++ b/mobile/src/terminal/document/selection-mode.ts @@ -0,0 +1,36 @@ +import type { TerminalDocumentScope } from './document-scope' +import { notify } from './host-notify' +import { repositionOverlay, stopEdgeScroll } from './selection-overlay' +import { seedWordSelection } from './selection-range' + +// Why: entering and leaving select mode drives the overlay; kept out of selection-range so the +// range math the overlay reads doesn't import the overlay back. + +export function cancelSelect(scope: TerminalDocumentScope) { + scope.selMode = 'navigate' + scope.sel = null + stopEdgeScroll(scope) + if (scope.term) { + try { + scope.term.clearSelection() + } catch {} + // Why: some xterm renderers cache cells and skip repaint on + // clearSelection alone, leaving the previously-highlighted cells + // visually selected. Force a full refresh so the selection layer + // actually clears on screen. + try { + scope.term.refresh(0, scope.term.rows - 1) + } catch {} + } + scope.selectionOverlay!.classList.remove('active') + notify(scope, { type: 'set-select-mode', enabled: false }) +} + +export function enterSelect(scope: TerminalDocumentScope, col: number, absRow: number) { + scope.selMode = 'select' + seedWordSelection(scope, col, absRow) + scope.selectionOverlay!.classList.add('active') + notify(scope, { type: 'set-select-mode', enabled: true }) + notify(scope, { type: 'haptic', kind: 'selection' }) + repositionOverlay(scope) +} diff --git a/mobile/src/terminal/document/selection-overlay.ts b/mobile/src/terminal/document/selection-overlay.ts index 5c2f89e367b..6d411413c2a 100644 --- a/mobile/src/terminal/document/selection-overlay.ts +++ b/mobile/src/terminal/document/selection-overlay.ts @@ -1,10 +1,9 @@ +import { getCellHeight, getTotalScale } from './cell-metrics' import { cellToViewportPx } from './cell-geometry' import type { TerminalDocumentScope } from './document-scope' -import { getCellHeight } from './fit-scale' import { notify } from './host-notify' import { applyXtermSelection, selRange } from './selection-range' import { viewportPoint, viewportToCell } from './viewport-cell' -import { getTotalScale } from './viewport-transform' /** How close to an edge a handle drag starts scrolling, in pixels. */ const EDGE_SCROLL_PX = 40 diff --git a/mobile/src/terminal/document/selection-range.ts b/mobile/src/terminal/document/selection-range.ts index ce32ccdcd0c..bde95116758 100644 --- a/mobile/src/terminal/document/selection-range.ts +++ b/mobile/src/terminal/document/selection-range.ts @@ -1,7 +1,5 @@ import { getLineText } from './cell-geometry' import type { TerminalDocumentScope, TerminalDocumentSelection } from './document-scope' -import { notify } from './host-notify' -import { repositionOverlay, stopEdgeScroll } from './selection-overlay' /** What counts as one word for select-all and for word seeding. */ const WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u @@ -87,32 +85,3 @@ export function applyXtermSelection(scope: TerminalDocumentScope) { scope.term.select(r.start.col, r.start.row, length) } catch {} } - -export function cancelSelect(scope: TerminalDocumentScope) { - scope.selMode = 'navigate' - scope.sel = null - stopEdgeScroll(scope) - if (scope.term) { - try { - scope.term.clearSelection() - } catch {} - // Why: some xterm renderers cache cells and skip repaint on - // clearSelection alone, leaving the previously-highlighted cells - // visually selected. Force a full refresh so the selection layer - // actually clears on screen. - try { - scope.term.refresh(0, scope.term.rows - 1) - } catch {} - } - scope.selectionOverlay!.classList.remove('active') - notify(scope, { type: 'set-select-mode', enabled: false }) -} - -export function enterSelect(scope: TerminalDocumentScope, col: number, absRow: number) { - scope.selMode = 'select' - seedWordSelection(scope, col, absRow) - scope.selectionOverlay!.classList.add('active') - notify(scope, { type: 'set-select-mode', enabled: true }) - notify(scope, { type: 'haptic', kind: 'selection' }) - repositionOverlay(scope) -} diff --git a/mobile/src/terminal/document/selection-state-and-eviction.ts b/mobile/src/terminal/document/selection-state-and-eviction.ts index 5ff46219bee..65f394517b9 100644 --- a/mobile/src/terminal/document/selection-state-and-eviction.ts +++ b/mobile/src/terminal/document/selection-state-and-eviction.ts @@ -1,6 +1,6 @@ import { elementInRoot } from './document-host-seams' import { repositionOverlay } from './selection-overlay' -import { cancelSelect } from './selection-range' +import { cancelSelect } from './selection-mode' import { notify } from './host-notify' import type { TerminalDocumentScope } from './document-scope' diff --git a/mobile/src/terminal/document/surface-touch-gestures.ts b/mobile/src/terminal/document/surface-touch-gestures.ts index aad20c045f1..5f8d187cb7c 100644 --- a/mobile/src/terminal/document/surface-touch-gestures.ts +++ b/mobile/src/terminal/document/surface-touch-gestures.ts @@ -1,7 +1,8 @@ +import { getCellHeight, getTotalScale } from './cell-metrics' import type { TerminalDocumentScope } from './document-scope' import { scheduleDocumentFrame } from './document-frame-registry' import { touchesInRoot } from './document-host-seams' -import { clampPan, getCellHeight } from './fit-scale' +import { clampPan } from './fit-scale' import { notify } from './host-notify' import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag' import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding' @@ -17,7 +18,7 @@ import { MIN_TEXT_SCALE, snapToTextScalePreset } from './text-scaling' -import { getTotalScale, updateTransform } from './viewport-transform' +import { updateTransform } from './viewport-transform' import { viewportPoint } from './viewport-cell' import { attachSurfaceWheelHandler } from './wheel-scroll' diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts index dbf459c225f..b0cf9a1ec05 100644 --- a/mobile/src/terminal/document/tap-dispatch.ts +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -1,5 +1,5 @@ import { handleDragMove, stopEdgeScroll } from './selection-overlay' -import { cancelSelect, enterSelect } from './selection-range' +import { cancelSelect, enterSelect } from './selection-mode' import { notify } from './host-notify' import { viewportToCell } from './viewport-cell' import type { TerminalDocumentScope } from './document-scope' diff --git a/mobile/src/terminal/document/terminal-init.ts b/mobile/src/terminal/document/terminal-init.ts index 091321503b6..0df9228275b 100644 --- a/mobile/src/terminal/document/terminal-init.ts +++ b/mobile/src/terminal/document/terminal-init.ts @@ -14,7 +14,7 @@ import { } from './mouse-mode-decset-scan' import { captureInitialOscLinkTexts } from './osc-link-tap' import { attachTerminalQueryReplyBridge, resetTerminalDataReplyAuthority } from './query-reply' -import { cancelSelect } from './selection-range' +import { cancelSelect } from './selection-mode' import { resetEvictionCounter } from './selection-state-and-eviction' import { beginTerminalSurfaceSwap, commitTerminalSurfaceSwap } from './surface-swap' import { attachTermObservers } from './term-observers' diff --git a/mobile/src/terminal/document/text-scaling.ts b/mobile/src/terminal/document/text-scaling.ts index 88d7b5dd762..5fc9395e0db 100644 --- a/mobile/src/terminal/document/text-scaling.ts +++ b/mobile/src/terminal/document/text-scaling.ts @@ -1,10 +1,10 @@ +import { getCellHeight, getCellWidth } from './cell-metrics' import { elementInRoot } from './document-host-seams' import { TERMINAL_TEXT_SCALES } from '../terminal-text-scales' import type { TerminalDocumentScope } from './document-scope' import { scheduleDocumentFrame } from './document-frame-registry' -import { applyFitScale, getCellHeight } from './fit-scale' +import { applyFitScale } from './fit-scale' import { fitDimensionsFromCell } from '../terminal-grid-fit' -import { getCellWidth } from './viewport-transform' // Why: init() flips ready false on every re-init (live width reflow included) // while the old surface stays visible; a document-scoped latch drives the diff --git a/mobile/src/terminal/document/viewport-cell.ts b/mobile/src/terminal/document/viewport-cell.ts index 63718a91afa..b78a51b3eb6 100644 --- a/mobile/src/terminal/document/viewport-cell.ts +++ b/mobile/src/terminal/document/viewport-cell.ts @@ -1,6 +1,5 @@ +import { getCellHeight, getCellWidth, getTotalScale } from './cell-metrics' import type { TerminalDocumentScope } from './document-scope' -import { getCellHeight } from './fit-scale' -import { getCellWidth, getTotalScale } from './viewport-transform' /** * A client point in the grid's own frame, which is where pan, cells and overlays are measured. diff --git a/mobile/src/terminal/document/viewport-transform.ts b/mobile/src/terminal/document/viewport-transform.ts index 61190307db4..d0a26e36dc5 100644 --- a/mobile/src/terminal/document/viewport-transform.ts +++ b/mobile/src/terminal/document/viewport-transform.ts @@ -1,6 +1,7 @@ import { repositionOverlay } from './selection-overlay' import { shouldRouteScrollToTerminalInput } from './mouse-input-encoding' import type { TerminalDocumentScope } from './document-scope' +import { getCellWidth, getTotalScale } from './cell-metrics' // Why: after init() the initial scrollback applyFitScale may have run // against an empty buffer (or one without the widest line yet). Re-fit @@ -19,26 +20,6 @@ export function flog(scope: TerminalDocumentScope, tag: string, payload: Record< } catch {} } -/** 0 until the renderer has measured a cell. */ -export function getMeasuredCellHeight(scope: TerminalDocumentScope) { - const core = scope.term?._core - if (core && core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.height || 0 - } - return 0 -} - -export function getCellWidth(scope: TerminalDocumentScope) { - if (!scope.term || !scope.term._core) { - return 0 - } - const core = scope.term._core - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.width || 0 - } - return 0 -} - // Why: width measurement strategy. // 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses // to lay out and is independent of buffer content. It's the "logical @@ -67,10 +48,6 @@ export function computeFitScale(scope: TerminalDocumentScope) { return Math.min(1, vpWidth / termWidth) } -export function getTotalScale(scope: TerminalDocumentScope) { - return scope.currentScale * scope.userScale -} - export function updateTransform(scope: TerminalDocumentScope) { scope.surface!.style.transform = 'translate(' + scope.panX + 'px,' + scope.panY + 'px) scale(' + getTotalScale(scope) + ')' diff --git a/mobile/src/terminal/document/wheel-scroll.ts b/mobile/src/terminal/document/wheel-scroll.ts index 46c57aad88a..1674d775053 100644 --- a/mobile/src/terminal/document/wheel-scroll.ts +++ b/mobile/src/terminal/document/wheel-scroll.ts @@ -1,6 +1,5 @@ -import { getCellHeight } from './fit-scale' +import { getCellHeight, getTotalScale } from './cell-metrics' import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding' -import { getTotalScale } from './viewport-transform' import { dispatcherShouldBlockSurface } from './tap-dispatch' import { enqueueNormalBufferScrollDelta, diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 23bdd0cc79a..2fd56fa7768 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -44,14 +44,14 @@ vi.mock('./connection-log-background-flush', () => ({ subscribeConnectionLogBackgroundFlush: () => () => {} })) +import { RpcClientProvider } from './client-context' import { - RpcClientProvider, useDisconnectHostClient, useForceReconnect, useHostClient, usePrimeHosts, useRefreshHostClient -} from './client-context' +} from './host-client-hooks' import { useAllHostClients } from './use-all-host-clients' import { useRelayRecoveryStatus } from './client-context-connection-metrics' import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 0348c22105d..356ac12efa6 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -35,15 +35,6 @@ import { import type { ConnectionState, HostProfile } from './types' import type { RpcClientContextValue } from './rpc-client-context-contract' -export { - useDisconnectHostClient, - useForceReconnect, - useForgetHostClient, - useHostClient, - usePrimeHosts, - useRefreshHostClient -} from './host-client-hooks' - type StoreEntry = HostClientStoreEntry const Ctx = createContext(null) diff --git a/mobile/src/transport/client-context.web.tsx b/mobile/src/transport/client-context.web.tsx index a1606ba9853..ebb65532f34 100644 --- a/mobile/src/transport/client-context.web.tsx +++ b/mobile/src/transport/client-context.web.tsx @@ -7,15 +7,6 @@ import { BRIDGE_PAGE_CLIENT_ID } from '../mobile-web-shell/bridge/bridge-page-cl import type { ConnectionState, HostProfile } from './types' import type { RpcClientContextValue } from './rpc-client-context-contract' -export { - useDisconnectHostClient, - useForceReconnect, - useForgetHostClient, - useHostClient, - usePrimeHosts, - useRefreshHostClient -} from './host-client-hooks' - const Ctx = createContext(null) /** The page's own client, which is more than an `RpcClient`: the route seam reads the session off * it to decide which screens are this document's. Separate from `Ctx` so the shared contract above diff --git a/mobile/src/transport/host-open-recovery.test.tsx b/mobile/src/transport/host-open-recovery.test.tsx index d4bf4fe669a..6e2cc52c7f7 100644 --- a/mobile/src/transport/host-open-recovery.test.tsx +++ b/mobile/src/transport/host-open-recovery.test.tsx @@ -35,14 +35,14 @@ vi.mock('./connection-log-background-flush', () => ({ subscribeConnectionLogBackgroundFlush: () => () => {} })) +import { RpcClientProvider } from './client-context' import { useDisconnectHostClient, - RpcClientProvider, useForgetHostClient, useForceReconnect, useHostClient, useRefreshHostClient -} from './client-context' +} from './host-client-hooks' const HOST = { id: 'host-1', diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts index 44a9a94f86a..b158a8aeaa1 100644 --- a/mobile/src/transport/settings-host-client-lifecycle.test.ts +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -8,11 +8,10 @@ import { useAllHostClients } from './use-all-host-clients' import { useFocusedSettingsHostClients } from './settings-host-client-connections' import { RpcClientProvider, - useHostClient, - usePrimeHosts, useRpcClientContext, type RpcClientContextValue } from './client-context' +import { useHostClient, usePrimeHosts } from './host-client-hooks' import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' import type { RpcClient } from './rpc-client' import type { ConnectionState, HostProfile } from './types' diff --git a/mobile/src/transport/use-workspace-client.ts b/mobile/src/transport/use-workspace-client.ts index b147b1bbb52..3830976b487 100644 --- a/mobile/src/transport/use-workspace-client.ts +++ b/mobile/src/transport/use-workspace-client.ts @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react' -import { useHostClient } from './client-context' +import { useHostClient } from './host-client-hooks' import type { RpcClient } from './rpc-client' import type { ConnectionState } from './types' diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index ea99a509653..21837a94823 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -139,7 +139,7 @@ }, { "file": "src/components/AuthFailedBannerActions.web.tsx", - "reason": "The auth-failed banner offers Retry, Re-pair and Remove, and on the page only Re-pair works. /pair-scan sits outside the page's route root of app/h (config/scripts/mobile-web-app-route-manifest.mjs:6), so route-handoff.web.ts:207 hands the push to the shell, which opens the native scan screen and returns to the same page document on Back. Retry and Remove are inert here: forceReconnect is null (client-context.web.tsx:57, read through host-client-hooks.ts:88) and removal refuses (page-host-removal-refusal.ts). The banner is reachable on the page because the shell forwards the native client's state (mobile-web-shell/bridge-host.ts:301) and auth-failed is in the wire enum (bridge/bridge-envelope.ts:44). So this one renders Re-pair as native does and one line naming the app for reconnect and removal." + "reason": "The auth-failed banner offers Retry, Re-pair and Remove, and on the page only Re-pair works. /pair-scan sits outside the page's route root of app/h (config/scripts/mobile-web-app-route-manifest.mjs:6), so route-handoff.web.ts:207 hands the push to the shell, which opens the native scan screen and returns to the same page document on Back. Retry and Remove are inert here: forceReconnect is null (client-context.web.tsx:45, read through host-client-hooks.ts:88) and removal refuses (page-host-removal-refusal.ts). The banner is reachable on the page because the shell forwards the native client's state (mobile-web-shell/bridge-host.ts:301) and auth-failed is in the wire enum (bridge/bridge-envelope.ts:44). So this one renders Re-pair as native does and one line naming the app for reconnect and removal." }, { "file": "src/components/use-html-preview-link-grant.web.ts",