fix(mobile): refresh nested file caches and keep deep names reachable (#31697)

Reconnect refreshes only the root and retains deleted nested entries; interrupted children can remain loading. Invalidate child reads and refresh expanded folders. Bound deep indentation to keep filenames reachable.
This commit is contained in:
Neil
2026-10-21 21:20:45 -07:00
committed by GitHub
parent f49cddc3be
commit aec02a76ee
4 changed files with 150 additions and 18 deletions
@@ -1,4 +1,4 @@
import { createElement } from 'react'
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
@@ -32,7 +32,7 @@ vi.mock('react-native', async () => {
item: unknown
index: number
separators: Record<string, never>
}) => unknown
}) => ReactNode
}) =>
React.createElement(
'FlatList',
@@ -141,7 +141,7 @@ async function pressByLabel(
accessibilityLabel: string
): Promise<void> {
const pressable = renderer.root
.findAllByType('Pressable')
.findAll((node) => String(node.type) === 'Pressable')
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
if (!pressable) {
throw new Error(`Unable to find pressable: ${accessibilityLabel}`)
@@ -153,7 +153,7 @@ async function pressByLabel(
function renderedText(renderer: ReactTestRenderer): string {
return renderer.root
.findAllByType('Text')
.findAll((node) => String(node.type) === 'Text')
.flatMap((node) => node.props.children)
.join(' ')
}
@@ -250,6 +250,93 @@ describe('MobileFileExplorerPanel', () => {
expect(renderedText(renderer)).toContain('CHANGELOG.md')
})
it('refreshes an expanded folder after reconnect instead of keeping deleted children', async () => {
mockTransport.client = createMockClient({
'': [entry('src', true)],
src: [entry('deleted-while-offline.ts')]
})
const renderer = await renderExplorer()
await pressByLabel(renderer, 'Open folder src')
expect(renderedText(renderer)).toContain('deleted-while-offline.ts')
mockTransport.client = null
mockTransport.connectionState = 'disconnected'
await updateExplorer(renderer)
mockTransport.client = createMockClient({
'': [entry('src', true)],
src: [entry('created-while-offline.ts')]
})
mockTransport.connectionState = 'connected'
await updateExplorer(renderer)
expect(renderedText(renderer)).toContain('created-while-offline.ts')
expect(renderedText(renderer)).not.toContain('deleted-while-offline.ts')
await pressByLabel(renderer, 'Open folder src')
await pressByLabel(renderer, 'Open folder src')
expect(renderedText(renderer)).toContain('created-while-offline.ts')
act(() => renderer.unmount())
})
it('rereads a previously cached collapsed folder when reopened after reconnect', async () => {
mockTransport.client = createMockClient({
'': [entry('src', true)],
src: [entry('deleted-while-offline.ts')]
})
const renderer = await renderExplorer()
await pressByLabel(renderer, 'Open folder src')
await pressByLabel(renderer, 'Open folder src')
mockTransport.client = null
mockTransport.connectionState = 'disconnected'
await updateExplorer(renderer)
mockTransport.client = createMockClient({
'': [entry('src', true)],
src: [entry('created-while-offline.ts')]
})
mockTransport.connectionState = 'connected'
await updateExplorer(renderer)
await pressByLabel(renderer, 'Open folder src')
expect(renderedText(renderer)).toContain('created-while-offline.ts')
expect(renderedText(renderer)).not.toContain('deleted-while-offline.ts')
act(() => renderer.unmount())
})
it('lets a superseded folder load retry when the reconnect root refresh fails', async () => {
let finishOldChild: (response: RpcResponse) => void = () => {}
const oldChild = new Promise<RpcResponse>((resolve) => {
finishOldChild = resolve
})
mockTransport.client = {
sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) =>
params.relativePath === '' ? ok([entry('src', true)]) : oldChild
)
}
const renderer = await renderExplorer()
await pressByLabel(renderer, 'Open folder src')
mockTransport.client = null
mockTransport.connectionState = 'disconnected'
await updateExplorer(renderer)
const reconnected = {
sendRequest: vi.fn(async (): Promise<RpcResponse> => {
throw new Error('root refresh failed')
})
}
mockTransport.client = reconnected
mockTransport.connectionState = 'connected'
await updateExplorer(renderer)
await act(async () => {
finishOldChild(ok([entry('obsolete.ts')]))
await oldChild
})
reconnected.sendRequest.mockResolvedValue(ok([entry('current.ts')]))
await pressByLabel(renderer, 'Retry loading src')
expect(renderedText(renderer)).toContain('current.ts')
expect(renderedText(renderer)).not.toContain('obsolete.ts')
act(() => renderer.unmount())
})
it('keeps the cached tree when a post-reconnect root refresh fails', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')]
@@ -411,9 +498,11 @@ describe('MobileFileExplorerPanel', () => {
function retryButtons(renderer: ReactTestRenderer) {
return renderer.root
.findAllByType('Pressable')
.findAll((node) => String(node.type) === 'Pressable')
.filter((node) =>
node.findAllByType('Text').some((text) => text.props.children === 'Retry')
node
.findAll((node) => String(node.type) === 'Text')
.some((text) => text.props.children === 'Retry')
)
}
+45 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
@@ -9,6 +9,7 @@ import {
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { ChevronLeft, X } from 'lucide-react-native'
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
import { useForceReconnect } from '../transport/host-client-hooks'
import { useWorkspaceClient } from '../transport/use-workspace-client'
@@ -21,7 +22,7 @@ import {
type FileExplorerRow
} from './file-tree'
import type { RpcFailure } from '../transport/types'
import { colors } from '../theme/mobile-theme'
import { colors, spacing } from '../theme/mobile-theme'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
@@ -46,14 +47,16 @@ export function MobileFileExplorerPanel(props: {
}) {
const { hostId, worktreeId, name, embedded, onRequestClose } = props
const router = useWorkspaceRouteHandoff()
const executionHost = useWorkspaceExecutionHost()
const { client, state: connState } = useWorkspaceClient(hostId)
const forceReconnect = useForceReconnect()
const scopeRef = useRef('')
const scope = `${hostId}:${worktreeId}`
const scope = JSON.stringify([hostId, executionHost, worktreeId])
scopeRef.current = scope
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
const directoryCacheRef = useRef<DirectoryCache>({})
const [panelWidth, setPanelWidth] = useState(0)
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const [loading, setLoading] = useState(true)
@@ -162,6 +165,7 @@ export function MobileFileExplorerPanel(props: {
...prev,
[relativePath]: { entries }
}))
return 'directory' as const
} catch (err) {
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
@@ -210,9 +214,40 @@ export function MobileFileExplorerPanel(props: {
directoryCacheRef.current = directoryCache
}, [directoryCache])
const refreshExpandedDirectories = useEffectEvent(() => {
setDirectoryCache((previous) =>
Object.fromEntries(
Object.entries(previous).filter(([path]) => path === '' || expanded.has(path))
)
)
for (const relativePath of expanded) {
if (getDirectoryCacheState(directoryCacheRef.current, relativePath)) {
void loadDirectory(relativePath)
}
}
})
useEffect(() => {
void loadDirectory('')
}, [hostId, loadDirectory])
let cancelled = false
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
setDirectoryCache((previous) =>
Object.fromEntries(
Object.entries(previous).map(([path, state]) => [
path,
state?.loading ? { entries: state.entries, error: 'Folder load interrupted' } : state
])
)
)
void loadDirectory('').then((result) => {
// Legacy roots replace their entire synthesized cache; only readDir roots need child reads.
if (!cancelled && result === 'directory') {
refreshExpandedDirectories()
}
})
return () => {
cancelled = true
}
}, [scope, loadDirectory])
useEffect(() => {
if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) {
@@ -284,6 +319,7 @@ export function MobileFileExplorerPanel(props: {
return (
<MobileFileExplorerRow
item={item}
maximumIndentation={Math.max(0, panelWidth / 3 - spacing.lg)}
expanded={expanded}
onPreviewFile={previewFile}
onRetryDirectory={retryDirectory}
@@ -357,7 +393,10 @@ export function MobileFileExplorerPanel(props: {
// 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}>
<View
style={styles.container}
onLayout={({ nativeEvent }) => setPanelWidth(nativeEvent.layout.width)}
>
{embedded ? (
<View style={styles.header}>{headerBar}</View>
) : (
+10 -5
View File
@@ -16,6 +16,7 @@ import { canPreviewMobileFileRow } from './mobile-file-preview-navigation'
type Props = {
item: FileExplorerRow
maximumIndentation: number
expanded: ReadonlySet<string>
onPreviewFile: (relativePath: string, displayName: string) => void
onRetryDirectory: (relativePath: string) => void
@@ -23,11 +24,13 @@ type Props = {
}
export function MobileFileExplorerRow(props: Props) {
const { item, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } = props
const { item, maximumIndentation, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } =
props
const paddingLeft = spacing.lg + Math.min(item.depth * 18, maximumIndentation)
if (item.kind === 'loading') {
return (
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
<View style={[styles.inlineStatusRow, { paddingLeft }]}>
<View style={styles.chevronSpacer} />
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.inlineStatusText}>Loading...</Text>
@@ -37,7 +40,7 @@ export function MobileFileExplorerRow(props: Props) {
if (item.kind === 'error') {
return (
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
<View style={[styles.inlineStatusRow, { paddingLeft }]}>
<View style={styles.chevronSpacer} />
<Text style={styles.inlineErrorText} numberOfLines={1}>
{item.message || 'Unable to load folder'}
@@ -60,6 +63,7 @@ export function MobileFileExplorerRow(props: Props) {
return (
<TreeRow
item={item}
paddingLeft={paddingLeft}
expanded={expanded}
onPreviewFile={onPreviewFile}
onToggleDirectory={onToggleDirectory}
@@ -76,11 +80,12 @@ function isTreeNode(item: FileExplorerRow): item is TreeNode {
function TreeRow(props: {
item: TreeNode
paddingLeft: number
expanded: ReadonlySet<string>
onPreviewFile: (relativePath: string, displayName: string) => void
onToggleDirectory: (relativePath: string) => void
}) {
const { item, expanded, onPreviewFile, onToggleDirectory } = props
const { item, paddingLeft, expanded, onPreviewFile, onToggleDirectory } = props
const isDirectory = item.kind === 'directory'
const isExpanded = expanded.has(item.relativePath)
const previewable =
@@ -94,7 +99,7 @@ function TreeRow(props: {
<Pressable
style={({ pressed }) => [
styles.row,
{ paddingLeft: spacing.lg + item.depth * 18 },
{ paddingLeft },
pressed && !disabled && styles.rowPressed,
disabled && styles.rowDisabled
]}
-1
View File
@@ -21,7 +21,6 @@ src/components/mobile-agent-icon-gradient.test.ts
src/components/pr-sidebar/PRCommentsSection.test.ts
src/diagnostics/connection-diagnostics-submission.test.ts
src/dictation/mobile-dictation-setup.test.ts
src/files/MobileFileExplorerPanel.test.ts
src/files/MobileFileMarkdownPreview.test.ts
src/hooks/mobile-dictation-desktop-start.test.ts
src/host-edit-route-accessibility.test.ts