diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 1a07a0d6ecc..b107f34da97 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -14,7 +14,7 @@ import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native' import { loadHosts } from '../../../src/transport/host-store' import { useHostClient } from '../../../src/transport/client-context' import { colors, spacing } from '../../../src/theme/mobile-theme' -import { styles } from './accounts-screen-styles' +import { styles } from '../../../src/accounts/mobile-accounts-screen-styles' import { useNow } from '../../../src/hooks/use-now' import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons' import { diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5d9ba390035..8f6d7ca0c97 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -267,8 +267,8 @@ import { reconcileMobileSessionCreateWarningState } from '../../../../src/session/mobile-session-create-warning-state' import { colors, spacing } from '../../../../src/theme/mobile-theme' -import { styles } from './mobile-session-styles' -import { QuickCommandsTabButton } from './QuickCommandsTabButton' +import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton' +import { styles } from '../../../../src/session/mobile-session-styles' import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types' import type { DiffCommentActions, @@ -289,7 +289,7 @@ import type { TerminalCreateResult, TerminalGestureInputBucket, TerminalGestureInputQueue -} from './mobile-session-route-types' +} from '../../../../src/session/mobile-session-route-types' const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 diff --git a/mobile/app/h/[hostId]/accounts-screen-styles.ts b/mobile/src/accounts/mobile-accounts-screen-styles.ts similarity index 97% rename from mobile/app/h/[hostId]/accounts-screen-styles.ts rename to mobile/src/accounts/mobile-accounts-screen-styles.ts index 4a987b7a442..65074f340ae 100644 --- a/mobile/app/h/[hostId]/accounts-screen-styles.ts +++ b/mobile/src/accounts/mobile-accounts-screen-styles.ts @@ -1,5 +1,5 @@ import { StyleSheet } from 'react-native' -import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme' +import { colors, spacing, typography, radii } from '../theme/mobile-theme' export const styles = StyleSheet.create({ container: { diff --git a/mobile/src/expo-route-module-boundary.test.ts b/mobile/src/expo-route-module-boundary.test.ts new file mode 100644 index 00000000000..3870f32c216 --- /dev/null +++ b/mobile/src/expo-route-module-boundary.test.ts @@ -0,0 +1,121 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { basename, dirname, extname, join, relative } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const appDirectory = fileURLToPath(new URL('../app', import.meta.url)) +const routeSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? sourceFiles(path) : [path] + }) +} + +function isNonScreenExpoModule(path: string): boolean { + const fileName = basename(path) + if (/\+api\.[jt]sx?$/.test(fileName)) { + return true + } + return ( + dirname(relative(appDirectory, path)) === '.' && + /^\+(?:html|middleware|native-intent)\.[jt]sx?$/.test(fileName) + ) +} + +function isPlatformSpecificApiRoute(path: string): boolean { + return /\+api\.(?:android|ios|native|web)\.[jt]sx?$/.test(basename(path)) +} + +function hasDefaultExport(path: string, source: string): boolean { + const extension = extname(path) + const sourceFile = ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + extension === '.jsx' + ? ts.ScriptKind.JSX + : extension === '.js' + ? ts.ScriptKind.JS + : extension === '.tsx' + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS + ) + + return sourceFile.statements.some((statement) => { + if (ts.isExportAssignment(statement)) { + return !statement.isExportEquals + } + if (ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.exportClause) { + if (ts.isNamespaceExport(statement.exportClause)) { + return statement.exportClause.name.text === 'default' + } + return statement.exportClause.elements.some( + (element) => !element.isTypeOnly && element.name.text === 'default' + ) + } + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined + return ( + !ts.isInterfaceDeclaration(statement) && + !ts.isTypeAliasDeclaration(statement) && + modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword) !== true && + modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true && + modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) + ) + }) +} + +function isInvalidRouteModule(path: string, source: string): boolean { + return ( + isPlatformSpecificApiRoute(path) || + (!isNonScreenExpoModule(path) && !hasDefaultExport(path, source)) + ) +} + +describe('Expo route module boundary', () => { + it('allows Expo modules that are not screen routes', () => { + expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ios.ts'))).toBe(false) + expect(isNonScreenExpoModule(join(appDirectory, '+html.tsx'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, '+middleware.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, '+native-intent.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, 'nested', '+middleware.ts'))).toBe(false) + }) + + it('recognizes syntax-level default exports', () => { + expect(hasDefaultExport('route.tsx', 'export default function Route() {}')).toBe(true) + expect( + hasDefaultExport('route.jsx', 'export default function Route() { return }') + ).toBe(true) + expect(hasDefaultExport('route.ts', "export { default } from './route-screen'")).toBe(true) + expect(hasDefaultExport('route.ts', "export { Route as default } from './route-screen'")).toBe( + true + ) + expect(hasDefaultExport('support.ts', '// export default')).toBe(false) + expect(hasDefaultExport('support.ts', "const marker = 'export default'")).toBe(false) + expect(hasDefaultExport('support.ts', 'export default interface Support {}')).toBe(false) + expect( + hasDefaultExport('support.ts', "export type { Support as default } from './types'") + ).toBe(false) + }) + + it('rejects platform-specific API routes even with a default export', () => { + expect(isPlatformSpecificApiRoute(join(appDirectory, 'health+api.ts'))).toBe(false) + for (const platform of ['android', 'ios', 'native', 'web']) { + const path = join(appDirectory, `health+api.${platform}.ts`) + expect(isInvalidRouteModule(path, 'export default function Route() {}')).toBe(true) + } + }) + + it('keeps support modules outside the app route directory', () => { + const invalidRoutes = sourceFiles(appDirectory) + .filter((path) => routeSourceExtensions.has(extname(path))) + .filter((path) => isInvalidRouteModule(path, readFileSync(path, 'utf8'))) + .map((path) => relative(appDirectory, path)) + + expect(invalidRoutes).toEqual([]) + }) +}) diff --git a/mobile/src/session/MobileBrowserTabActionSheet.tsx b/mobile/src/session/MobileBrowserTabActionSheet.tsx index 655aa717ab9..5f605d1395c 100644 --- a/mobile/src/session/MobileBrowserTabActionSheet.tsx +++ b/mobile/src/session/MobileBrowserTabActionSheet.tsx @@ -1,5 +1,5 @@ import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native' -import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { MobileSessionTab } from './mobile-session-route-types' import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal' import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent' diff --git a/mobile/src/session/MobileSessionHeaderIconButton.tsx b/mobile/src/session/MobileSessionHeaderIconButton.tsx index 68e1a1173a4..b1037bb958f 100644 --- a/mobile/src/session/MobileSessionHeaderIconButton.tsx +++ b/mobile/src/session/MobileSessionHeaderIconButton.tsx @@ -1,7 +1,7 @@ import type { ComponentType } from 'react' import { Pressable } from 'react-native' import { colors } from '../theme/mobile-theme' -import { styles } from '../../app/h/[hostId]/session/mobile-session-styles' +import { styles } from './mobile-session-styles' type HeaderIconProps = { size?: number diff --git a/mobile/app/h/[hostId]/session/QuickCommandsTabButton.tsx b/mobile/src/session/QuickCommandsTabButton.tsx similarity index 92% rename from mobile/app/h/[hostId]/session/QuickCommandsTabButton.tsx rename to mobile/src/session/QuickCommandsTabButton.tsx index 5faebb7691d..364a35daf75 100644 --- a/mobile/app/h/[hostId]/session/QuickCommandsTabButton.tsx +++ b/mobile/src/session/QuickCommandsTabButton.tsx @@ -1,7 +1,7 @@ import { Pressable, View } from 'react-native' import { SquareChevronRight } from 'lucide-react-native' -import { colors } from '../../../../src/theme/mobile-theme' +import { colors } from '../theme/mobile-theme' import { styles } from './mobile-session-styles' type Props = { diff --git a/mobile/src/session/mobile-bulk-close-sheet-actions.ts b/mobile/src/session/mobile-bulk-close-sheet-actions.ts index 8565575fe08..68dee5c2af3 100644 --- a/mobile/src/session/mobile-bulk-close-sheet-actions.ts +++ b/mobile/src/session/mobile-bulk-close-sheet-actions.ts @@ -1,7 +1,4 @@ -import type { - MarkdownDocState, - MobileSessionTab -} from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { MarkdownDocState, MobileSessionTab } from './mobile-session-route-types' import type { ActionSheetAction } from '../components/ActionSheetModal' import { BULK_TAB_CLOSE_ACTIONS, diff --git a/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts b/mobile/src/session/mobile-session-command-input-styles.ts similarity index 98% rename from mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts rename to mobile/src/session/mobile-session-command-input-styles.ts index 1bb852c4218..b19c13a502e 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts +++ b/mobile/src/session/mobile-session-command-input-styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native' -import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' export const mobileSessionCommandInputStyles = StyleSheet.create({ createWarningBanner: { diff --git a/mobile/app/h/[hostId]/session/mobile-session-frame-styles.ts b/mobile/src/session/mobile-session-frame-styles.ts similarity index 97% rename from mobile/app/h/[hostId]/session/mobile-session-frame-styles.ts rename to mobile/src/session/mobile-session-frame-styles.ts index 738632ba495..a02c14be014 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-frame-styles.ts +++ b/mobile/src/session/mobile-session-frame-styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native' -import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' export const mobileSessionFrameStyles = StyleSheet.create({ container: { diff --git a/mobile/app/h/[hostId]/session/mobile-session-reader-styles.ts b/mobile/src/session/mobile-session-reader-styles.ts similarity index 97% rename from mobile/app/h/[hostId]/session/mobile-session-reader-styles.ts rename to mobile/src/session/mobile-session-reader-styles.ts index aba5340ec7a..eedd47d328b 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-reader-styles.ts +++ b/mobile/src/session/mobile-session-reader-styles.ts @@ -1,6 +1,6 @@ import { Platform, StyleSheet } from 'react-native' -import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' export const mobileSessionReaderStyles = StyleSheet.create({ markdownTextInput: { diff --git a/mobile/app/h/[hostId]/session/mobile-session-review-comment-styles.ts b/mobile/src/session/mobile-session-review-comment-styles.ts similarity index 98% rename from mobile/app/h/[hostId]/session/mobile-session-review-comment-styles.ts rename to mobile/src/session/mobile-session-review-comment-styles.ts index b9d19578b83..8d8b2d11ccd 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-review-comment-styles.ts +++ b/mobile/src/session/mobile-session-review-comment-styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native' -import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' export const mobileSessionReviewCommentStyles = StyleSheet.create({ diffCommentAddButton: { diff --git a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts similarity index 85% rename from mobile/app/h/[hostId]/session/mobile-session-route-types.ts rename to mobile/src/session/mobile-session-route-types.ts index c5a94e9d38f..7e74a5d04c0 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts +++ b/mobile/src/session/mobile-session-route-types.ts @@ -1,13 +1,10 @@ -import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane' -import type { MobileTerminalTheme } from '../../../../src/terminal/terminal-webview-contract' -import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines' -import type { - MobileHighlightedDiffLine, - MobileSyntaxSegment -} from '../../../../src/session/mobile-file-syntax' -import type { TerminalRecord } from '../../../../src/session/mobile-terminal-records' -import type { DiffComment, TuiAgent } from '../../../../../src/shared/types' -import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types' +import type { DiffComment, TuiAgent } from '../../../src/shared/types' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { MobileBrowserTab } from '../browser/MobileBrowserPane' +import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract' +import type { MobileDiffLine } from './mobile-diff-lines' +import type { MobileHighlightedDiffLine, MobileSyntaxSegment } from './mobile-file-syntax' +import type { TerminalRecord } from './mobile-terminal-records' export type Terminal = TerminalRecord diff --git a/mobile/app/h/[hostId]/session/mobile-session-styles.ts b/mobile/src/session/mobile-session-styles.ts similarity index 100% rename from mobile/app/h/[hostId]/session/mobile-session-styles.ts rename to mobile/src/session/mobile-session-styles.ts diff --git a/mobile/src/session/mobile-terminal-tab-agent.test.ts b/mobile/src/session/mobile-terminal-tab-agent.test.ts index fcc7452e989..8449f988e24 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.test.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { TuiAgent } from '../../../src/shared/types' -import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { MobileSessionTab } from './mobile-session-route-types' import { getMobileSessionTabTitle, resolveMobileTerminalTabAgentId diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index 1ca19521b32..731d5bf4a24 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -3,7 +3,7 @@ import { resolveExplicitTerminalTitleAgentType } from '../../../src/shared/termi import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { TuiAgent } from '../../../src/shared/types' import { isBlankBrowserUrl } from '../browser/browser-url' -import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { MobileSessionTab } from './mobile-session-route-types' // Why: tab identity + title cleaning uses the same shared glyph/label maps as // desktop, so the two platforms do not drift on which titles identify agents. diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index cc681e2056e..0125cb053e6 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -10,7 +10,7 @@ const liveInputStatusSource = readFileSync( 'utf8' ) const commandInputStylesSource = readFileSync( - new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url), + new URL('../session/mobile-session-command-input-styles.ts', import.meta.url), 'utf8' ) const liveInputFocusSource = readFileSync(