mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
* fix(mobile): retire file save continuations when preview closes * fix(mobile): retire file saves only on unmount, warn when Discard interrupts a save --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import { Alert, Pressable, Text, View } from 'react-native'
|
|
import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context'
|
|
import { ChevronLeft, Save } from 'lucide-react-native'
|
|
import { translate } from '../i18n/mobile-i18n'
|
|
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
|
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
|
|
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
|
|
import { getWorktreeLabel } from '../session/worktree-label'
|
|
import { colors, spacing } from '../theme/mobile-theme'
|
|
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
|
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 { mobileMarkdownFileLink } from './mobile-markdown-file-link'
|
|
import { useMobileFilePreviewBack } from './use-mobile-file-preview-back'
|
|
|
|
type Props = {
|
|
route: MobileFilePreviewRouteState
|
|
}
|
|
|
|
export function MobileFilePreviewScreen({ route }: Props) {
|
|
const executionHost = useWorkspaceExecutionHost()
|
|
const documentKey = route.ok
|
|
? [
|
|
route.params.hostId,
|
|
executionHost,
|
|
sourceKeyForPreview(previewSourceFromRoute(route.params))
|
|
]
|
|
: ['invalid', route.message]
|
|
return <MobileFilePreviewDocument key={JSON.stringify(documentKey)} route={route} />
|
|
}
|
|
|
|
function MobileFilePreviewDocument({ route }: Props) {
|
|
const t = useMobileTranslation()
|
|
useMobileColorScheme()
|
|
// Use the shared route handoff to keep this screen within the bundle 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: t('files.filePreviewScreen.loading', 'Loading preview...') }
|
|
: previewError(route.message)
|
|
)
|
|
const [draftContent, setDraftContent] = useState('')
|
|
const [savedContent, setSavedContent] = useState('')
|
|
const [saveError, setSaveError] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const savingRef = useRef(false)
|
|
const saveRevisionRef = useRef(0)
|
|
useLayoutEffect(
|
|
() => () => {
|
|
saveRevisionRef.current += 1
|
|
},
|
|
[]
|
|
)
|
|
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)
|
|
// Grant-only refreshes keep the source key, so loads read the source here instead of re-running.
|
|
const previewSourceRef = useRef(previewSource)
|
|
const previewLoadRevisionRef = useRef(0)
|
|
const lineColumn = useMemo(
|
|
() =>
|
|
previewParams
|
|
? normalizeMobileFilePreviewLineColumn(previewParams.line, previewParams.column)
|
|
: null,
|
|
[previewParams]
|
|
)
|
|
|
|
useEffect(() => {
|
|
setPreviewSource(routePreviewSource)
|
|
}, [routePreviewSource])
|
|
|
|
useEffect(() => {
|
|
previewSourceKeyRef.current = previewSourceKey
|
|
}, [previewSourceKey])
|
|
|
|
useEffect(() => {
|
|
previewSourceRef.current = previewSource
|
|
}, [previewSource])
|
|
|
|
useEffect(() => {
|
|
draftContentRef.current = draftContent
|
|
}, [draftContent])
|
|
|
|
useEffect(() => {
|
|
savedContentRef.current = savedContent
|
|
}, [savedContent])
|
|
|
|
const loadPreview = useCallback(async () => {
|
|
const revision = ++previewLoadRevisionRef.current
|
|
const loadSourceKey = previewSourceKey
|
|
const loadSource = previewSourceRef.current
|
|
const isCurrent = () =>
|
|
revision === previewLoadRevisionRef.current && previewSourceKeyRef.current === loadSourceKey
|
|
if (!previewParams || !loadSource || loadSourceKey !== routePreviewSourceKey) {
|
|
setPreview(previewError(route.ok ? '' : route.message))
|
|
return
|
|
}
|
|
const preserveDirtyDraft =
|
|
draftSourceKeyRef.current === previewSourceKey &&
|
|
draftContentRef.current !== savedContentRef.current
|
|
if (!client || connState !== 'connected') {
|
|
if (preserveDirtyDraft) {
|
|
setSaveError(translate('files.filePreviewScreen.waiting', 'Waiting for desktop...'))
|
|
return
|
|
}
|
|
setPreview({
|
|
status: 'waiting',
|
|
message: translate('files.filePreviewScreen.waiting', 'Waiting for desktop...'),
|
|
reconnect: true
|
|
})
|
|
return
|
|
}
|
|
if (!preserveDirtyDraft) {
|
|
setPreview({
|
|
status: 'loading',
|
|
message: translate('files.filePreviewScreen.loading', 'Loading preview...')
|
|
})
|
|
}
|
|
setSaveError('')
|
|
try {
|
|
const result = await loadMobileFilePreview(client, loadSource, undefined, {
|
|
isCurrent,
|
|
onTerminalArtifactSourceRefreshed: (source) => {
|
|
if (isCurrent()) {
|
|
previewSourceRef.current = source
|
|
setPreviewSource(source)
|
|
}
|
|
},
|
|
refreshGrant: true
|
|
})
|
|
if (!isCurrent()) {
|
|
return
|
|
}
|
|
if (shouldKeepDirtyDraftOnPreviewLoadResult(preserveDirtyDraft, result)) {
|
|
setSaveError(result.message)
|
|
return
|
|
}
|
|
// A refreshed preview can become truncated or binary while this editor owns a dirty draft.
|
|
if (preserveDirtyDraft) {
|
|
return
|
|
}
|
|
const loadedContent =
|
|
result.status === 'ready' && result.kind !== 'image' && result.kind !== 'media'
|
|
? result.content
|
|
: result.status === 'empty'
|
|
? ''
|
|
: null
|
|
if (loadedContent !== null) {
|
|
setDraftContent(loadedContent)
|
|
setSavedContent(loadedContent)
|
|
draftSourceKeyRef.current = previewSourceKey
|
|
}
|
|
setPreview(result)
|
|
} catch (err) {
|
|
if (!isCurrent()) {
|
|
return
|
|
}
|
|
const message = err instanceof Error ? err.message : null
|
|
if (preserveDirtyDraft) {
|
|
setSaveError(
|
|
message ?? translate('files.filePreviewScreen.loadFailed', 'Unable to load preview')
|
|
)
|
|
return
|
|
}
|
|
setPreview(previewError(message ?? ''))
|
|
}
|
|
}, [client, connState, previewParams, previewSourceKey, route, routePreviewSourceKey])
|
|
|
|
useEffect(() => {
|
|
void loadPreview()
|
|
return () => {
|
|
previewLoadRevisionRef.current += 1
|
|
}
|
|
}, [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}`
|
|
: t('files.filePreviewScreen.title', '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 ||
|
|
savingRef.current
|
|
) {
|
|
return
|
|
}
|
|
savingRef.current = true
|
|
const saveRevision = ++saveRevisionRef.current
|
|
const isCurrentSave = () => saveRevisionRef.current === saveRevision
|
|
setSaving(true)
|
|
setSaveError('')
|
|
try {
|
|
const result = await saveMobileTerminalArtifactPreview(client, previewSource, draftContent, {
|
|
baseContent: savedContent,
|
|
isCurrent: isCurrentSave,
|
|
onTerminalArtifactSourceRefreshed: (source) => {
|
|
if (isCurrentSave()) {
|
|
setPreviewSource(source)
|
|
}
|
|
}
|
|
})
|
|
if (!isCurrentSave()) {
|
|
return
|
|
}
|
|
if (result.status === 'saved') {
|
|
setSavedContent(draftContent)
|
|
} else {
|
|
setSaveError(saveErrorMessageFromPreviewResult(result))
|
|
}
|
|
} catch (err) {
|
|
if (!isCurrentSave()) {
|
|
return
|
|
}
|
|
const message =
|
|
err instanceof Error
|
|
? err.message
|
|
: translate('files.filePreviewScreen.saveFailed', 'Unable to save file')
|
|
setSaveError(message)
|
|
} finally {
|
|
if (isCurrentSave()) {
|
|
savingRef.current = false
|
|
setSaving(false)
|
|
}
|
|
}
|
|
}, [canSaveArtifact, client, draftContent, previewSource, savedContent])
|
|
|
|
// Unmount retires pending saves; a refused back keeps the document and its save alive.
|
|
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={t('files.filePreviewScreen.backToFiles', 'Back to files')}
|
|
>
|
|
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
|
|
</Pressable>
|
|
<View style={styles.titleBlock}>
|
|
<Text style={styles.title} numberOfLines={1}>
|
|
{title || t('files.filePreviewScreen.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={t(
|
|
'files.filePreviewScreen.saveArtifact',
|
|
'Save terminal artifact'
|
|
)}
|
|
>
|
|
<Save size={18} color={colors.textPrimary} strokeWidth={2.2} />
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
</SafeAreaView>
|
|
<MobileFilePreviewBody
|
|
preview={preview}
|
|
client={client}
|
|
worktreeId={previewSource?.source === 'worktree' ? previewSource.worktreeId : undefined}
|
|
relativePath={displayPath}
|
|
title={title || t('files.filePreviewScreen.fileTitle', '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: t('files.filePreviewScreen.loadFailed', 'Unable to load preview'),
|
|
reconnect: false
|
|
})
|
|
}
|
|
onRetry={retry}
|
|
onOpenFile={(pathText) => {
|
|
const href = previewParams && mobileMarkdownFileLink(previewParams, pathText)
|
|
if (href) {
|
|
router.push(href)
|
|
} else {
|
|
Alert.alert(
|
|
t('files.filePreviewScreen.openFailed', 'Unable to open file'),
|
|
t(
|
|
'files.filePreviewScreen.chooseWorkspaceFile',
|
|
'Choose a file inside this workspace.'
|
|
)
|
|
)
|
|
}
|
|
}}
|
|
/>
|
|
<ConfirmModal
|
|
visible={confirmingDiscard}
|
|
title={t('files.filePreviewScreen.discardTitle', 'Discard changes?')}
|
|
message={
|
|
saving
|
|
? t(
|
|
'files.filePreviewScreen.discardMessageSaving',
|
|
'The save in progress may not finish. Unsaved edits will be lost.'
|
|
)
|
|
: t('files.filePreviewScreen.discardMessage', 'Unsaved edits will be lost.')
|
|
}
|
|
confirmLabel={t('files.filePreviewScreen.discard', 'Discard')}
|
|
cancelLabel={t('files.filePreviewScreen.stay', 'Stay')}
|
|
destructive
|
|
onConfirm={discard}
|
|
onCancel={stay}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function saveErrorMessageFromPreviewResult(result: MobileFilePreviewResult): string {
|
|
return result.status === 'error' || result.status === 'waiting'
|
|
? result.message
|
|
: translate('files.filePreviewScreen.saveFailed', 'Unable to save file')
|
|
}
|