mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Extract mobile host and browser helpers (#16779)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import {
|
||||
@@ -69,8 +69,9 @@ import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { colors, spacing } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles'
|
||||
import { leaveHostRoute } from '../../../src/host-route-exit'
|
||||
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
|
||||
import {
|
||||
@@ -1410,310 +1411,3 @@ export default function HostWorktreeRoute() {
|
||||
function ListSeparator() {
|
||||
return <View style={styles.separator} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topChrome: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 34,
|
||||
paddingTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sidebarCollapseButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
hostIdentity: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
marginRight: spacing.md
|
||||
},
|
||||
hostNameText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
reconnectButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbar: {
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
embeddedFilterChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
embeddedModeButton: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
filterChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
filterChipActive: {
|
||||
borderColor: colors.textSecondary,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
modeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sortLabel: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
floatingWorkspaceHeaderButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
embeddedToolbarIconButton: {
|
||||
flex: 1,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
toolbarIconDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
searchToggle: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
list: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
sectionIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionRepoIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
sectionCount: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginLeft: spacing.lg + 24,
|
||||
marginRight: spacing.lg
|
||||
},
|
||||
filterModalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
filterGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
gap: spacing.sm
|
||||
},
|
||||
filterRowText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
filterRepoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
confirmContent: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
confirmTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
confirmMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
confirmButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
confirmBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
confirmBtnCancel: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmBtnDestructive: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
confirmBtnPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
confirmBtnCancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmBtnDestructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
|
||||
// can't resolve Node's builtin in a React Native bundle.
|
||||
import { Buffer } from 'buffer'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -10,7 +7,6 @@ import {
|
||||
PanResponder,
|
||||
PixelRatio,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
@@ -24,7 +20,7 @@ import type {
|
||||
BrowserScreencastFrame,
|
||||
BrowserScreencastFrameMetadata
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
|
||||
buildMobileBrowserScreencastRequest,
|
||||
@@ -47,11 +43,32 @@ import {
|
||||
computeBrowserTouchClickRadiusCss,
|
||||
mapScreenToBrowserPoint,
|
||||
readLocalTouchPoint,
|
||||
type BrowserFrameGeometry,
|
||||
type BrowserPoint,
|
||||
type BrowserTouchLayout,
|
||||
type BrowserZoomState
|
||||
} from './browser-touch-geometry'
|
||||
import {
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
assertRpcOk,
|
||||
browserErrorMessage,
|
||||
browserFrameMetadataEqual,
|
||||
buttonColor,
|
||||
cacheBrowserFrame,
|
||||
clearCachedBrowserFramesForWorktree,
|
||||
createBrowserFrameDataUri,
|
||||
createPinchGesture,
|
||||
getCachedBrowserFrame,
|
||||
makeBrowserFrameCacheKey,
|
||||
peekCachedBrowserFrame,
|
||||
shouldSurfaceBrowserError,
|
||||
updateBrowserImageSource,
|
||||
updateBrowserLayerVisibility,
|
||||
updatePinchZoom,
|
||||
type FrameLayer,
|
||||
type PinchGesture
|
||||
} from './mobile-browser-frame-state'
|
||||
import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
|
||||
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
|
||||
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
|
||||
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
|
||||
@@ -79,15 +96,6 @@ type MobileBrowserPaneProps = {
|
||||
onToast: (message: string, durationMs?: number) => void
|
||||
}
|
||||
|
||||
type FrameLayer = 0 | 1
|
||||
|
||||
type PinchGesture = {
|
||||
distance: number
|
||||
scale: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
}
|
||||
|
||||
type PanGesture = {
|
||||
x: number
|
||||
y: number
|
||||
@@ -105,17 +113,7 @@ const SCROLL_START_SLOP = 22
|
||||
const LONG_PRESS_MS = 550
|
||||
const WHEEL_INTERVAL_MS = 70
|
||||
const TOUCH_CLICK_RADIUS_DIP = 14
|
||||
const MIN_ZOOM = 1
|
||||
const MAX_ZOOM = 3.5
|
||||
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
|
||||
const BROWSER_FRAME_CACHE_LIMIT = 4
|
||||
|
||||
type BrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
|
||||
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
|
||||
|
||||
type BrowserPageParams = {
|
||||
worktree: string
|
||||
@@ -1268,350 +1266,3 @@ export function MobileBrowserPane({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function buttonColor(enabled: boolean): string {
|
||||
return enabled ? colors.textSecondary : colors.textMuted
|
||||
}
|
||||
|
||||
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
|
||||
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
|
||||
}
|
||||
|
||||
function makeBrowserFrameCacheKey(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null,
|
||||
viewMode: MobileBrowserViewMode
|
||||
): string | null {
|
||||
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
|
||||
}
|
||||
|
||||
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
|
||||
const prefix = `${worktreeId}:`
|
||||
for (const key of browserFrameCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
browserFrameCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
if (!cacheKey) {
|
||||
return null
|
||||
}
|
||||
const cached = browserFrameCache.get(cacheKey)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
|
||||
}
|
||||
|
||||
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
|
||||
if (!cacheKey) {
|
||||
return
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, entry)
|
||||
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
|
||||
const oldestKey = browserFrameCache.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
break
|
||||
}
|
||||
browserFrameCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function updateBrowserLayerVisibility(
|
||||
layers: [View | null, View | null],
|
||||
visible: FrameLayer
|
||||
): void {
|
||||
for (const [index, layer] of layers.entries()) {
|
||||
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
function updateBrowserImageSource(image: Image | null, uri: string): void {
|
||||
// Why: browser frames are large strings; mutating only the native Image
|
||||
// source avoids re-rendering the whole tab view for every streamed frame.
|
||||
const source = [{ uri }]
|
||||
image?.setNativeProps({ source, src: source })
|
||||
}
|
||||
|
||||
function assertRpcOk(
|
||||
response: RpcSuccess | RpcFailure,
|
||||
fallbackMessage: string
|
||||
): asserts response is RpcSuccess {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message || fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
function browserFrameMetadataEqual(
|
||||
a: BrowserScreencastFrameMetadata | null,
|
||||
b: BrowserScreencastFrameMetadata
|
||||
): boolean {
|
||||
return (
|
||||
a?.deviceWidth === b.deviceWidth &&
|
||||
a?.deviceHeight === b.deviceHeight &&
|
||||
a?.pageScaleFactor === b.pageScaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
function browserErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
function shouldSurfaceBrowserError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
// Why: selector_not_found can be emitted by in-flight page automation while
|
||||
// the browser is still usable; replacing the frame with it feels like a crash.
|
||||
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
|
||||
}
|
||||
|
||||
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
|
||||
const touches = event.nativeEvent.touches
|
||||
if (!touches || touches.length < 2) {
|
||||
return null
|
||||
}
|
||||
const a = readLocalTouchPoint(touches[0])
|
||||
const b = readLocalTouchPoint(touches[1])
|
||||
return a && b ? { a, b } : null
|
||||
}
|
||||
|
||||
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
function createPinchGesture(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
zoom: BrowserZoomState
|
||||
): PinchGesture | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const distance = pointDistance(pair.a, pair.b)
|
||||
if (distance < 8) {
|
||||
return null
|
||||
}
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
|
||||
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
|
||||
return {
|
||||
distance,
|
||||
scale: zoom.scale,
|
||||
anchorX: (centerX - frameCenterX) / zoom.scale,
|
||||
anchorY: (centerY - frameCenterY) / zoom.scale
|
||||
}
|
||||
}
|
||||
|
||||
function updatePinchZoom(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
pinch: PinchGesture
|
||||
): BrowserZoomState | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const nextScale = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
|
||||
)
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
|
||||
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
|
||||
return clampBrowserZoomState(
|
||||
{
|
||||
scale: nextScale,
|
||||
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
|
||||
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
|
||||
},
|
||||
geometry,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
toolbar: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
browserImageHost: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImageFill: {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
browserImageLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserImageLayerHidden: {
|
||||
opacity: 0
|
||||
},
|
||||
browserZoomOffset: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserFrameBox: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImage: {
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.2)'
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
dialogOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.5)'
|
||||
},
|
||||
dialogCard: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel,
|
||||
padding: spacing.lg
|
||||
},
|
||||
dialogTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogMessage: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
dialogActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
dialogButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dialogButtonPrimary: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
dialogButtonPressed: {
|
||||
opacity: 0.75
|
||||
},
|
||||
dialogButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogButtonPrimaryText: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
keyboardDock: {
|
||||
zIndex: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs + 2
|
||||
},
|
||||
keyboardInput: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.35
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import type { GestureResponderEvent, Image, View } from 'react-native'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import type {
|
||||
BrowserScreencastFrame,
|
||||
BrowserScreencastFrameMetadata
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
clampBrowserZoomState,
|
||||
readLocalTouchPoint,
|
||||
type BrowserFrameGeometry,
|
||||
type BrowserPoint,
|
||||
type BrowserZoomState
|
||||
} from './browser-touch-geometry'
|
||||
import type { MobileBrowserViewMode } from './browser-screencast-request'
|
||||
|
||||
export type FrameLayer = 0 | 1
|
||||
export type PinchGesture = {
|
||||
distance: number
|
||||
scale: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
}
|
||||
export type BrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
export const MIN_ZOOM = 1
|
||||
export const MAX_ZOOM = 3.5
|
||||
const BROWSER_FRAME_CACHE_LIMIT = 4
|
||||
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
|
||||
|
||||
export function buttonColor(enabled: boolean): string {
|
||||
return enabled ? colors.textSecondary : colors.textMuted
|
||||
}
|
||||
|
||||
export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
|
||||
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
|
||||
}
|
||||
|
||||
export function makeBrowserFrameCacheKey(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null,
|
||||
viewMode: MobileBrowserViewMode
|
||||
): string | null {
|
||||
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
|
||||
}
|
||||
|
||||
export function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
|
||||
const prefix = `${worktreeId}:`
|
||||
for (const key of browserFrameCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
browserFrameCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
if (!cacheKey) {
|
||||
return null
|
||||
}
|
||||
const cached = browserFrameCache.get(cacheKey)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
|
||||
}
|
||||
|
||||
export function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
|
||||
if (!cacheKey) {
|
||||
return
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, entry)
|
||||
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
|
||||
const oldestKey = browserFrameCache.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
break
|
||||
}
|
||||
browserFrameCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBrowserLayerVisibility(
|
||||
layers: [View | null, View | null],
|
||||
visible: FrameLayer
|
||||
): void {
|
||||
for (const [index, layer] of layers.entries()) {
|
||||
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBrowserImageSource(image: Image | null, uri: string): void {
|
||||
// Why: browser frames are large strings; mutating only the native Image
|
||||
// source avoids re-rendering the whole tab view for every streamed frame.
|
||||
const source = [{ uri }]
|
||||
image?.setNativeProps({ source, src: source })
|
||||
}
|
||||
|
||||
export function assertRpcOk(
|
||||
response: RpcSuccess | RpcFailure,
|
||||
fallbackMessage: string
|
||||
): asserts response is RpcSuccess {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message || fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
export function browserFrameMetadataEqual(
|
||||
a: BrowserScreencastFrameMetadata | null,
|
||||
b: BrowserScreencastFrameMetadata
|
||||
): boolean {
|
||||
return (
|
||||
a?.deviceWidth === b.deviceWidth &&
|
||||
a?.deviceHeight === b.deviceHeight &&
|
||||
a?.pageScaleFactor === b.pageScaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
export function browserErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
export function shouldSurfaceBrowserError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
// Why: selector_not_found can be emitted by in-flight page automation while
|
||||
// the browser is still usable; replacing the frame with it feels like a crash.
|
||||
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
|
||||
}
|
||||
|
||||
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
|
||||
const touches = event.nativeEvent.touches
|
||||
if (!touches || touches.length < 2) {
|
||||
return null
|
||||
}
|
||||
const a = readLocalTouchPoint(touches[0])
|
||||
const b = readLocalTouchPoint(touches[1])
|
||||
return a && b ? { a, b } : null
|
||||
}
|
||||
|
||||
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
export function createPinchGesture(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
zoom: BrowserZoomState
|
||||
): PinchGesture | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const distance = pointDistance(pair.a, pair.b)
|
||||
if (distance < 8) {
|
||||
return null
|
||||
}
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
|
||||
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
|
||||
return {
|
||||
distance,
|
||||
scale: zoom.scale,
|
||||
anchorX: (centerX - frameCenterX) / zoom.scale,
|
||||
anchorY: (centerY - frameCenterY) / zoom.scale
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePinchZoom(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
pinch: PinchGesture
|
||||
): BrowserZoomState | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const nextScale = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
|
||||
)
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
|
||||
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
|
||||
return clampBrowserZoomState(
|
||||
{
|
||||
scale: nextScale,
|
||||
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
|
||||
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
|
||||
},
|
||||
geometry,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const mobileBrowserPaneStyles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
toolbar: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
browserImageHost: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImageFill: {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
browserImageLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserImageLayerHidden: {
|
||||
opacity: 0
|
||||
},
|
||||
browserZoomOffset: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserFrameBox: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImage: {
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.2)'
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
dialogOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.5)'
|
||||
},
|
||||
dialogCard: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel,
|
||||
padding: spacing.lg
|
||||
},
|
||||
dialogTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogMessage: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
dialogActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
dialogButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dialogButtonPrimary: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
dialogButtonPressed: {
|
||||
opacity: 0.75
|
||||
},
|
||||
dialogButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogButtonPrimaryText: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
keyboardDock: {
|
||||
zIndex: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs + 2
|
||||
},
|
||||
keyboardInput: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.35
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const hostScreenPrimaryStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topChrome: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 34,
|
||||
paddingTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sidebarCollapseButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
hostIdentity: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
marginRight: spacing.md
|
||||
},
|
||||
hostNameText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
reconnectButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbar: {
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
embeddedFilterChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
embeddedModeButton: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
filterChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
filterChipActive: {
|
||||
borderColor: colors.textSecondary,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
modeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sortLabel: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
floatingWorkspaceHeaderButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
embeddedToolbarIconButton: {
|
||||
flex: 1,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
toolbarIconDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
searchToggle: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const hostScreenSecondaryStyles = StyleSheet.create({
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
list: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
sectionIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionRepoIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
sectionCount: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginLeft: spacing.lg + 24,
|
||||
marginRight: spacing.lg
|
||||
},
|
||||
filterModalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
filterGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
gap: spacing.sm
|
||||
},
|
||||
filterRowText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
filterRepoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
confirmContent: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
confirmTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
confirmMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
confirmButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
confirmBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
confirmBtnCancel: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmBtnDestructive: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
confirmBtnPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
confirmBtnCancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmBtnDestructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { hostScreenPrimaryStyles } from './host-screen-primary-styles'
|
||||
import { hostScreenSecondaryStyles } from './host-screen-secondary-styles'
|
||||
|
||||
export const hostScreenStyles = {
|
||||
...hostScreenPrimaryStyles,
|
||||
...hostScreenSecondaryStyles
|
||||
}
|
||||
Reference in New Issue
Block a user