diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts index 47db7d64656..0c358ce6652 100644 --- a/mobile/src/files/MobileFileExplorerPanel.test.ts +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -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 - }) => unknown + }) => ReactNode }) => React.createElement( 'FlatList', @@ -141,7 +141,7 @@ async function pressByLabel( accessibilityLabel: string ): Promise { 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((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 => { + 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') ) } diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index a6078ec7510..cb07684c739 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -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(createDirectoryLoadRevisions()) const pendingDirectoryRetriesRef = useRef>(new Set()) const directoryCacheRef = useRef({}) + const [panelWidth, setPanelWidth] = useState(0) const [directoryCache, setDirectoryCache] = useState({}) const [expanded, setExpanded] = useState>(() => 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 ( + setPanelWidth(nativeEvent.layout.width)} + > {embedded ? ( {headerBar} ) : ( diff --git a/mobile/src/files/mobile-file-explorer-row.tsx b/mobile/src/files/mobile-file-explorer-row.tsx index fb72494bd12..8d4e550ed24 100644 --- a/mobile/src/files/mobile-file-explorer-row.tsx +++ b/mobile/src/files/mobile-file-explorer-row.tsx @@ -16,6 +16,7 @@ import { canPreviewMobileFileRow } from './mobile-file-preview-navigation' type Props = { item: FileExplorerRow + maximumIndentation: number expanded: ReadonlySet 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 ( - + Loading... @@ -37,7 +40,7 @@ export function MobileFileExplorerRow(props: Props) { if (item.kind === 'error') { return ( - + {item.message || 'Unable to load folder'} @@ -60,6 +63,7 @@ export function MobileFileExplorerRow(props: Props) { return ( 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: { [ styles.row, - { paddingLeft: spacing.lg + item.depth * 18 }, + { paddingLeft }, pressed && !disabled && styles.rowPressed, disabled && styles.rowDisabled ]} diff --git a/mobile/tests-typecheck-baseline.txt b/mobile/tests-typecheck-baseline.txt index 81700c5cb0b..10bb77f63a3 100644 --- a/mobile/tests-typecheck-baseline.txt +++ b/mobile/tests-typecheck-baseline.txt @@ -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