Files
orca/mobile/src/files/MobileFilePreviewScreen.tsx
T
Neil aa07d5891a fix(mobile): break the transport and terminal-document import cycles, and gate cycles after mobile's install (#30290)
* fix(mobile): break the transport and terminal-document import cycles and gate them after mobile's install

client-context re-exported the host-client hooks, which import client-context back: the re-export
barrel is gone and importers (and their test mocks) name host-client-hooks directly. In the terminal
document, the cell-metric getters move to a leaf cell-metrics module and select-mode entry/exit to
selection-mode, so fit, transform, overlay and range no longer import each other. pr.yml lints imports
before mobile's install, when mobile's tsconfig can't load, so Mobile verify now runs the same import
rules over mobile after its install with zero warnings allowed.

* fix(mobile): the browser-pane render harness imports useHostClient from host-client-hooks

Its route source is a template string, so no typecheck saw the removed client-context re-export; the mobile web bundle check did.

* fix(ci): the e2e typecheck ratchet counts the same with or without mobile's install

Specs reach mobile/src/transport, whose expo-crypto and async-storage imports resolve only after mobile's install and then load React Native's globals (numeric timers, a required NODE_ENV), adding errors in unrelated specs. An empty stub pins both, so the static job reads 232 either way.
2026-10-18 04:00:24 -07:00

300 lines
10 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context'
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/host-client-hooks'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { connectionRetryAction } from '../transport/connection-retry-action'
import {
loadMobileFilePreview,
previewError,
saveMobileTerminalArtifactPreview,
type MobileFilePreviewSource,
type MobileFilePreviewResult
} from './mobile-file-preview-request'
import { ConfirmModal } from '../components/ConfirmModal'
import { MobileFilePreviewBody } from './MobileFilePreviewBody'
import {
displayNameFromPreviewPath,
type MobileFilePreviewRouteState
} from './mobile-file-preview-route'
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
import { normalizeMobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
import {
hasUnsavedMobileTerminalArtifactDraft,
isEditableMobileTerminalArtifactPreview,
shouldKeepDirtyDraftOnPreviewLoadResult
} from './mobile-file-preview-editability'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
import { useMobileFilePreviewBack } from './use-mobile-file-preview-back'
type Props = {
route: MobileFilePreviewRouteState
}
export function MobileFilePreviewScreen({ route }: Props) {
// Why: every workspace screen uses the handoff so it shares the route chunk; else the bundle overruns its asset budget.
const router = useWorkspaceRouteHandoff()
const previewParams = route.ok ? route.params : null
const { client, state: connState } = useWorkspaceClient(previewParams?.hostId)
const forceReconnect = useForceReconnect()
const [preview, setPreview] = useState<MobileFilePreviewResult>(() =>
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
)
const [draftContent, setDraftContent] = useState('')
const [savedContent, setSavedContent] = useState('')
const [saveError, setSaveError] = useState('')
const [saving, setSaving] = useState(false)
const draftContentRef = useRef(draftContent)
const savedContentRef = useRef(savedContent)
const draftSourceKeyRef = useRef<string | null>(null)
const { width, height } = useSafeAreaFrame()
const routePreviewSource = useMemo(
() => (previewParams ? previewSourceFromRoute(previewParams) : null),
[previewParams]
)
const [previewSource, setPreviewSource] = useState<MobileFilePreviewSource | null>(
routePreviewSource
)
const previewSourceKey = useMemo(() => sourceKeyForPreview(previewSource), [previewSource])
const routePreviewSourceKey = useMemo(
() => sourceKeyForPreview(routePreviewSource),
[routePreviewSource]
)
const previewSourceKeyRef = useRef(previewSourceKey)
const lineColumn = useMemo(
() =>
previewParams
? normalizeMobileFilePreviewLineColumn(previewParams.line, previewParams.column)
: null,
[previewParams]
)
useEffect(() => {
setPreviewSource(routePreviewSource)
draftSourceKeyRef.current = null
}, [routePreviewSource])
useEffect(() => {
previewSourceKeyRef.current = previewSourceKey
}, [previewSourceKey])
useEffect(() => {
draftContentRef.current = draftContent
}, [draftContent])
useEffect(() => {
savedContentRef.current = savedContent
}, [savedContent])
const loadPreview = useCallback(async () => {
const loadSourceKey = previewSourceKey
if (!previewParams || !previewSource || loadSourceKey !== routePreviewSourceKey) {
setPreview(previewError(route.ok ? 'Unable to load preview' : route.message))
return
}
const preserveDirtyDraft =
draftSourceKeyRef.current === previewSourceKey &&
draftContentRef.current !== savedContentRef.current
if (!client || connState !== 'connected') {
if (preserveDirtyDraft) {
setSaveError('Waiting for desktop...')
return
}
setPreview({ status: 'waiting', message: 'Waiting for desktop...', reconnect: true })
return
}
if (!preserveDirtyDraft) {
setPreview({ status: 'loading', message: 'Loading preview...' })
}
setSaveError('')
try {
const result = await loadMobileFilePreview(client, previewSource, undefined, {
onTerminalArtifactSourceRefreshed: setPreviewSource,
refreshGrant: true
})
if (previewSourceKeyRef.current !== loadSourceKey) {
return
}
if (shouldKeepDirtyDraftOnPreviewLoadResult(preserveDirtyDraft, result)) {
setSaveError(result.message)
return
}
const loadedContent =
result.status === 'ready' && result.kind !== 'image' && result.kind !== 'media'
? result.content
: result.status === 'empty'
? ''
: null
if (loadedContent !== null) {
if (!preserveDirtyDraft) {
setDraftContent(loadedContent)
setSavedContent(loadedContent)
}
draftSourceKeyRef.current = previewSourceKey
}
setPreview(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to load preview'
if (preserveDirtyDraft) {
setSaveError(message)
return
}
setPreview(previewError(message))
}
}, [
client,
connState,
previewParams,
previewSource,
previewSourceKey,
route,
routePreviewSourceKey
])
useEffect(() => {
void loadPreview()
}, [loadPreview])
const retry = useMemo(
() =>
connectionRetryAction({
hostId: previewParams?.hostId,
needsReconnect:
preview.status === 'waiting' ||
(preview.status === 'error' && preview.reconnect) ||
connState !== 'connected',
forceReconnect,
reload: () => void loadPreview()
}),
[connState, forceReconnect, loadPreview, preview, previewParams]
)
const displayPath =
previewParams?.source === 'terminalArtifact'
? (previewParams.absolutePath ?? '')
: (previewParams?.relativePath ?? '')
const title = previewParams?.name ?? displayNameFromPreviewPath(displayPath)
const worktreeLabel = getWorktreeLabel(
previewParams?.worktreeName,
previewParams?.worktreeId ?? ''
)
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
const isEditableTerminalArtifact =
previewSource?.source === 'terminalArtifact' &&
isEditableMobileTerminalArtifactPreview(preview, previewSource.readOnly === true)
const canSaveArtifact =
isEditableTerminalArtifact &&
draftSourceKeyRef.current === previewSourceKey &&
draftContent !== savedContent
const hasUnsavedTerminalArtifactDraft = hasUnsavedMobileTerminalArtifactDraft({
source: previewSource?.source,
draftSourceKey: draftSourceKeyRef.current,
previewSourceKey,
draftContent,
savedContent
})
const saveArtifact = useCallback(async () => {
if (!client || previewSource?.source !== 'terminalArtifact' || !canSaveArtifact || saving) {
return
}
setSaving(true)
setSaveError('')
try {
const result = await saveMobileTerminalArtifactPreview(client, previewSource, draftContent, {
baseContent: savedContent,
onTerminalArtifactSourceRefreshed: setPreviewSource
})
if (result.status === 'saved') {
setSavedContent(draftContent)
} else {
setSaveError(saveErrorMessageFromPreviewResult(result))
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to save file'
setSaveError(message)
} finally {
setSaving(false)
}
}, [canSaveArtifact, client, draftContent, previewSource, savedContent, saving])
const leave = useCallback(() => router.back(), [router])
const { confirmingDiscard, requestBack, stay, discard } = useMobileFilePreviewBack({
hasUnsavedDraft: hasUnsavedTerminalArtifactDraft,
leave
})
return (
<View style={styles.container}>
<SafeAreaView style={styles.header} edges={['top']}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={requestBack}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back to files"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<View style={styles.titleBlock}>
<Text style={styles.title} numberOfLines={1}>
{title || 'Preview'}
</Text>
<Text style={styles.meta} numberOfLines={1}>
{meta}
</Text>
</View>
{isEditableTerminalArtifact ? (
<Pressable
style={[styles.saveButton, (!canSaveArtifact || saving) && styles.saveButtonDisabled]}
onPress={() => void saveArtifact()}
disabled={!canSaveArtifact || saving}
accessibilityLabel="Save terminal artifact"
>
<Save size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
) : null}
</View>
</SafeAreaView>
<MobileFilePreviewBody
preview={preview}
client={client}
relativePath={displayPath}
title={title || 'File'}
editable={isEditableTerminalArtifact}
draftContent={draftContent}
saveError={saveError}
lineColumn={lineColumn}
imageWidth={Math.max(1, width - spacing.md * 2)}
imageHeight={Math.max(240, height - 160)}
onDraftChange={setDraftContent}
onImageError={() =>
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false })
}
onRetry={retry}
/>
<ConfirmModal
visible={confirmingDiscard}
title="Discard changes?"
message="Unsaved edits will be lost."
confirmLabel="Discard"
cancelLabel="Stay"
destructive
onConfirm={discard}
onCancel={stay}
/>
</View>
)
}
function saveErrorMessageFromPreviewResult(result: MobileFilePreviewResult): string {
return result.status === 'error' || result.status === 'waiting'
? result.message
: 'Unable to save file'
}