Files
orca/mobile/src/files/MobileFileExplorerPanel.tsx
T
Jinwoo Hong 0e6862cbcc fix(mobile): the page offers no control whose only effect is a re-dial it cannot make (#22326)
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials

Six failed-load screens share one Retry shape: re-dial a host that is not
connected, otherwise re-read. On the page the re-dial is inert
(`client-context.web.tsx:55`) and each screen's load already re-runs when the
shell's client reconnects, so in the disconnected state that Retry did
nothing at all. `connectionRetryAction` makes the decision once and answers
null when a re-dial is needed and none exists; agent history, the file
explorer root, the file preview, git history, the source-control status gate
and the diff review render no Retry for null.

The explorer's per-folder Retry keeps its control: it queues the folder, and
the queue drains on the next `connected` whoever brought it back.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): the page offers no re-dial, so no header offers one

`forceReconnect` on the page was `() => Promise.resolve()`: the shell owns
the connection and nothing in the document can re-dial it. The host header's
Reconnect and the session header's "tap to retry" were wired to it and did
nothing there. The context member is now nullable and the page's provider
hands out null, so the compiler found every caller: both headers render no
reconnect affordance for null, and the session status keeps the verdict
label without promising a tap.

Native providers and the recording adapters still pass a function, so
nothing a phone renders changes. The session route's host-JSX parity hash
moves for the header's extra null check; the page test doubles that stubbed
the old inert re-dial now stub null.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): the auth-failed banner cites the page's re-dial as null

Three comments and the banner's override reason still said the page's
`forceReconnect` was an inert `() => Promise.resolve()`, and cited
`client-context.web.tsx` lines the previous commit moved. They now say null
and point at the lines that hold it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the session closure gains the page's Retry decision

`connection-retry-action.ts` is the one module the Retry fix adds to the
session route's page closure, reached through the explorer, source control
and git history it docks. Measured on this head with all five generators run
first, and diffed against the pre-change closure: one local module added,
none removed.

Session route closure 4207 -> 4208 modules, local 1021 -> 1022.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): the capability probe belongs on the page, and says why

The push fence excluded `runtime-capability-probe.ts` because the session
route and the host screen run it. The session half holds, and the probe
works there: `status.get` carries no client identity and makes no write, the
shell forwards it like any non-`native.` request, and the desktop's mobile
allowlist admits it. The host-screen half no longer does:
`codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which
the bundle carries and the page hands to the native screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): keep the session retry test's cast under its disable line

The formatter wrapped the cast onto the line after the disable comment,
which left it uncovered. The cast now sits on its own line directly below
the SAFETY note.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the agent-history Retry test mocks the pathname the handoff reads

Main's page route handoff now subscribes to `usePathname` (#22300), and the
Retry suite this branch added mounts that handoff with an `expo-router` mock
that lacked it. Same one-line addition main made to the back-handoff suite.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the reload each hidden page Retry relies on

Hiding a Retry on the page rests on the screen's load re-running when the
shell's client reconnects, because nothing on the page re-dials. Only the
explorer's folder drain pinned that. Each other site now has a case that
starts unreachable with no Retry and asserts the load goes out on the
client and state the reconnect delivers: agent history (status.get), file
preview (the preview read), diff review (the snapshot load), git history
(git.history) and source-control status (git.status, in the loaders suite
because the panel test mocks the state hook).

Each goes red when the `client`/`connState` dependencies it guards are
removed; for source control that is both `loadStatus` and the
`loadBranchCompare` it depends on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): one import of the transport types in the source-control loaders test

CI's native code-quality audit denies the duplicate-import warning the reload pin added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 19:41:38 -04:00

376 lines
13 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
Pressable,
Text,
View,
type ListRenderItem
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { ChevronLeft, X } from 'lucide-react-native'
import { useRouteHandoff } from '../navigation/route-handoff'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { connectionRetryAction } from '../transport/connection-retry-action'
import { getWorktreeLabel } from '../session/worktree-label'
import {
flattenDirectoryCache,
getDirectoryCacheState,
type DirectoryCache,
type FileExplorerRow
} from './file-tree'
import type { RpcFailure } from '../transport/types'
import { colors } from '../theme/mobile-theme'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
} from './directory-load-revisions'
import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
import { fileDirectoryRead, legacyFileListRead } from './mobile-file-explorer-operations'
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
export function MobileFileExplorerPanel(props: {
hostId: string
worktreeId: string
name?: string
embedded?: boolean
onRequestClose?: () => void
}) {
const { hostId, worktreeId, name, embedded, onRequestClose } = props
const router = useRouteHandoff()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const scopeRef = useRef('')
const scope = `${hostId}:${worktreeId}`
scopeRef.current = scope
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
const directoryCacheRef = useRef<DirectoryCache>({})
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
const loadDirectory = useCallback(
async (relativePath: string) => {
const scope = scopeRef.current
const loadToken = beginDirectoryLoad(directoryLoadRevisionsRef.current, scope, relativePath)
const rootLoad = relativePath === ''
if (!client || connState !== 'connected') {
const message =
connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...'
if (rootLoad) {
const hasLoadedRoot =
(getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
setLoading(false)
// Why: transient reconnects should not blank an already browsable tree.
setError(hasLoadedRoot ? null : message)
} else {
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
}
return
}
const hadLoadedRoot =
rootLoad && (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
if (rootLoad) {
// Why: a reconnect refresh must not blank an already browsable tree —
// the full-screen spinner unmounts the list and resets scroll.
if (!hadLoadedRoot) {
setLoading(true)
}
setError(null)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
loading: true
}
}))
try {
const response = await fileDirectoryRead.request(client, {
worktree: `id:${worktreeId}`,
relativePath
})
const directory = fileDirectoryRead.interpret(response)
if (!directory.accepted) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this policy skips only a refusal, so an unaccepted reply is a failure envelope.
const refusal = (response as RpcFailure).error
// Why: desktops that predate the files.readDir mobile allowlist
// entry still serve the capped files.list; fall back so the Files
// tab keeps working until the desktop updates.
if (rootLoad && isMobileMethodUnavailableError(refusal?.code, refusal?.message)) {
const legacyReply = await legacyFileListRead.request(client, {
worktree: `id:${worktreeId}`
})
const legacy = legacyFileListRead.interpret(legacyReply)
if (legacy.accepted) {
if (
!isCurrentDirectoryLoad(
directoryLoadRevisionsRef.current,
scopeRef.current,
loadToken
)
) {
return
}
const legacyResult = legacy.value
setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
// Why: the capped list silently omits files past the cap — keep
// the legacy explorer's "Showing first 5000" note.
setLegacyListTruncated(legacyResult.truncated)
return
}
throw new Error(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this policy skips only a refusal, so an unaccepted reply is a failure envelope.
(legacyReply as RpcFailure).error?.message ||
refusal?.message ||
'Unable to load files'
)
}
throw new Error(refusal?.message || 'Unable to load files')
}
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
) {
return
}
const entries = directory.value
if (rootLoad) {
setLegacyListTruncated(false)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: { entries }
}))
} catch (err) {
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
) {
return
}
const message = err instanceof Error ? err.message : 'Unable to load files'
if (rootLoad) {
// Why: a failed background refresh keeps the cached tree browsable;
// only a cold load surfaces the full-screen error.
setError(hadLoadedRoot ? null : message)
} else {
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
}
} finally {
if (
rootLoad &&
isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
) {
setLoading(false)
}
}
},
[client, connState, worktreeId]
)
useEffect(() => {
scopeRef.current = scope
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
pendingDirectoryRetriesRef.current.clear()
directoryCacheRef.current = {}
setDirectoryCache({})
setExpanded(new Set())
setLoading(true)
setError(null)
setLegacyListTruncated(false)
}, [scope])
useEffect(() => {
directoryCacheRef.current = directoryCache
}, [directoryCache])
useEffect(() => {
void loadDirectory('')
}, [hostId, loadDirectory])
useEffect(() => {
if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) {
return
}
const pending = [...pendingDirectoryRetriesRef.current]
pendingDirectoryRetriesRef.current.clear()
for (const relativePath of pending) {
void loadDirectory(relativePath)
}
}, [connState, loadDirectory])
const rows = useMemo(
() => flattenDirectoryCache(directoryCache, expanded),
[directoryCache, expanded]
)
const toggleDirectory = useCallback(
(relativePath: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(relativePath)) {
next.delete(relativePath)
} else {
next.add(relativePath)
}
return next
})
const state = getDirectoryCacheState(directoryCache, relativePath)
if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
void loadDirectory(relativePath)
}
},
[directoryCache, expanded, loadDirectory]
)
const retryDirectory = useCallback(
(relativePath: string) => {
if (connState !== 'connected' && hostId) {
// Still worth a tap on the page, where nothing re-dials: the queued read runs when the
// shell's client reconnects on its own.
pendingDirectoryRetriesRef.current.add(relativePath)
void forceReconnect?.(hostId)
return
}
void loadDirectory(relativePath)
},
[connState, forceReconnect, hostId, loadDirectory]
)
const previewFile = useCallback(
(relativePath: string, displayName: string) => {
navigateToMobileFilePreview(
router,
{
hostId,
worktreeId,
relativePath,
name: displayName,
worktreeName: name
},
{ embedded, onRequestClose }
)
},
[embedded, hostId, name, onRequestClose, router, worktreeId]
)
const renderItem: ListRenderItem<FileExplorerRow> = ({ item }) => {
return (
<MobileFileExplorerRow
item={item}
expanded={expanded}
onPreviewFile={previewFile}
onRetryDirectory={retryDirectory}
onToggleDirectory={toggleDirectory}
/>
)
}
const headerBar = (
<View style={styles.topBar}>
{embedded ? (
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={() => onRequestClose?.()}
hitSlop={8}
accessibilityLabel="Close files"
>
<X size={20} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : (
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={() => router.back()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back to session"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
)}
<View style={styles.titleBlock}>
<Text style={styles.title} numberOfLines={1}>
Files
</Text>
<Text style={styles.meta} numberOfLines={1}>
{worktreeLabel}
{legacyListTruncated ? ' - Showing first 5000' : ''}
</Text>
</View>
</View>
)
// Why: while disconnected, re-sending the request is useless — revive the parked transport
// instead (issue #5049); loadDirectory re-runs via its effect once the new client connects.
const rootRetry = connectionRetryAction({
hostId,
needsReconnect: connState !== 'connected',
forceReconnect,
reload: () => void loadDirectory('')
})
const body = loading ? (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : error ? (
<View style={styles.state}>
<Text style={styles.errorText}>{error}</Text>
{rootRetry ? (
<Pressable style={styles.retryButton} onPress={rootRetry}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
) : null}
</View>
) : rows.length === 0 ? (
<View style={styles.state}>
<Text style={styles.emptyText}>No files found</Text>
</View>
) : (
<FlatList
data={rows}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
style={styles.list}
/>
)
// Embedded: the dock column owns safe-area/layout, so render a plain View and
// a non-inset header. Full-screen: keep the SafeAreaView top inset + chrome.
return (
<View style={styles.container}>
{embedded ? (
<View style={styles.header}>{headerBar}</View>
) : (
<SafeAreaView style={styles.header} edges={['top']}>
{headerBar}
</SafeAreaView>
)}
{body}
</View>
)
}