mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
]}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user