Show full workspace file tree on mobile (#7289)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-06 15:41:51 -07:00 committed by GitHub
parent a393facef8
commit 99ae2dc90f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 925 additions and 211 deletions

View File

@ -21,6 +21,31 @@ const MOCK_FILE_LIST = [
{ relativePath: 'dist/archive.zip', basename: 'archive.zip', kind: 'binary' }
]
function readMockDirectory(relativePath: string): Array<{ name: string; isDirectory: boolean }> {
const prefix = relativePath ? `${relativePath}/` : ''
const children = new Map<string, boolean>()
for (const file of MOCK_FILE_LIST) {
if (!file.relativePath.startsWith(prefix)) {
continue
}
const rest = file.relativePath.slice(prefix.length)
if (!rest) {
continue
}
const [name, ...descendants] = rest.split('/')
if (!name) {
continue
}
children.set(name, children.get(name) === true || descendants.length > 0)
}
return Array.from(children, ([name, isDirectory]) => ({ name, isDirectory })).sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
})
}
export function handleMockFilePreviewRequest(
request: RpcRequest,
respond: Respond,
@ -40,6 +65,10 @@ export function handleMockFilePreviewRequest(
)
return true
case 'files.readDir':
respond(success(request.id, readMockDirectory(String(request.params?.relativePath ?? ''))))
return true
case 'files.read': {
const relativePath = String(request.params?.relativePath ?? '')
const content = MOCK_FILE_CONTENT[relativePath]

View File

@ -0,0 +1,227 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
import type { MobileDirEntry } from './file-tree'
import type { RpcResponse } from '../transport/types'
type MockClient = {
sendRequest: ReturnType<typeof vi.fn>
}
const mockTransport = vi.hoisted(() => ({
client: null as MockClient | null,
connectionState: 'connected',
forceReconnect: vi.fn()
}))
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
FlatList: (props: {
data: unknown[]
keyExtractor: (item: unknown, index: number) => string
renderItem: (info: {
item: unknown
index: number
separators: Record<string, never>
}) => unknown
}) =>
React.createElement(
'FlatList',
props,
props.data.map((item, index) =>
React.createElement(
'FlatListItem',
{ key: props.keyExtractor(item, index) },
props.renderItem({ item, index, separators: {} })
)
)
),
Pressable: 'Pressable',
StyleSheet: {
create: (styles: unknown) => styles,
hairlineWidth: 1
},
Text: 'Text',
View: 'View'
}
})
vi.mock('react-native-safe-area-context', () => ({
SafeAreaView: 'SafeAreaView'
}))
vi.mock('expo-router', () => ({
useRouter: () => ({
back: vi.fn(),
push: vi.fn()
})
}))
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight',
File: 'File',
FileText: 'FileText',
Folder: 'Folder',
Image: 'Image',
X: 'X'
}))
vi.mock('../platform/haptics', () => ({
triggerSelection: vi.fn()
}))
vi.mock('../transport/client-context', () => ({
useForceReconnect: () => mockTransport.forceReconnect,
useHostClient: () => ({
client: mockTransport.client,
state: mockTransport.connectionState
})
}))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function entry(name: string, isDirectory = false): MobileDirEntry {
return { name, isDirectory }
}
function ok(result: MobileDirEntry[]): RpcResponse {
return { id: 'response-id', ok: true, result, _meta: { runtimeId: 'runtime-id' } }
}
function createMockClient(entriesByPath: Record<string, MobileDirEntry[]>): MockClient {
return {
sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) => {
return ok(entriesByPath[params.relativePath] ?? [])
})
}
}
async function renderExplorer(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileFileExplorerPanel, {
hostId: 'host-a',
worktreeId: 'worktree-a',
name: 'Example Worktree',
embedded: true
})
)
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('MobileFileExplorerPanel did not render')
}
return renderer
}
async function updateExplorer(renderer: ReactTestRenderer): Promise<void> {
await act(async () => {
renderer.update(
createElement(MobileFileExplorerPanel, {
hostId: 'host-a',
worktreeId: 'worktree-a',
name: 'Example Worktree',
embedded: true
})
)
})
}
async function pressByLabel(
renderer: ReactTestRenderer,
accessibilityLabel: string
): Promise<void> {
const pressable = renderer.root
.findAllByType('Pressable')
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
if (!pressable) {
throw new Error(`Unable to find pressable: ${accessibilityLabel}`)
}
await act(async () => {
pressable.props.onPress()
})
}
function renderedText(renderer: ReactTestRenderer): string {
return renderer.root
.findAllByType('Text')
.flatMap((node) => node.props.children)
.join(' ')
}
describe('MobileFileExplorerPanel', () => {
afterEach(() => {
vi.restoreAllMocks()
mockTransport.client = null
mockTransport.connectionState = 'connected'
mockTransport.forceReconnect = vi.fn()
})
it('loads directories lazily and reuses cached children when reopened', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')],
src: [entry('app.ts'), entry('components', true)]
})
mockTransport.client = client
const renderer = await renderExplorer()
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
worktree: 'id:worktree-a',
relativePath: ''
})
await pressByLabel(renderer, 'Open folder src')
await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledTimes(2))
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
worktree: 'id:worktree-a',
relativePath: 'src'
})
expect(renderedText(renderer)).toContain('app.ts')
await pressByLabel(renderer, 'Open folder src')
await pressByLabel(renderer, 'Open folder src')
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('keeps the loaded tree visible during a transient disconnect', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')]
})
mockTransport.client = client
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('src')
mockTransport.client = null
mockTransport.connectionState = 'disconnected'
await updateExplorer(renderer)
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(renderedText(renderer)).toContain('src')
expect(renderedText(renderer)).toContain('README.md')
expect(renderedText(renderer)).not.toContain('Waiting for desktop...')
})
})

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
@ -9,34 +9,28 @@ import {
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import {
ChevronDown,
ChevronLeft,
ChevronRight,
File,
FileText,
Folder,
Image as ImageIcon,
X
} from 'lucide-react-native'
import { ChevronLeft, X } from 'lucide-react-native'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { getWorktreeLabel } from '../session/worktree-label'
import {
buildTree,
flattenTree,
isMarkdownPath,
type FilesListResult,
type MobileFileEntry,
type TreeNode
flattenDirectoryCache,
getDirectoryCacheState,
type DirectoryCache,
type FileExplorerRow,
type MobileDirEntry
} from './file-tree'
import type { RpcSuccess } from '../transport/types'
import { triggerSelection } from '../platform/haptics'
import { colors, spacing } from '../theme/mobile-theme'
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { colors } from '../theme/mobile-theme'
import {
canPreviewMobileFileRow,
navigateToMobileFilePreview
} from './mobile-file-preview-navigation'
beginDirectoryLoad,
createDirectoryLoadRevisions,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
} from './directory-load-revisions'
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
@ -49,58 +43,173 @@ export function MobileFileExplorerPanel(props: {
const router = useRouter()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const [files, setFiles] = useState<MobileFileEntry[]>([])
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 [truncated, setTruncated] = useState(false)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
const loadFiles = useCallback(async () => {
if (!client || connState !== 'connected') {
setLoading(false)
setError(connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...')
return
}
setLoading(true)
setError(null)
try {
const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` })
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to load files')
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 result = (response as RpcSuccess).result as FilesListResult
setFiles(result.files)
setTruncated(result.truncated)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load files')
} finally {
setLoading(false)
}
}, [client, connState, worktreeId])
if (rootLoad) {
setLoading(true)
setError(null)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
loading: true
}
}))
try {
const response = await client.sendRequest('files.readDir', {
worktree: `id:${worktreeId}`,
relativePath
})
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to load files')
}
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
) {
return
}
const entries = (response as RpcSuccess).result as MobileDirEntry[]
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) {
setError(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(() => {
void loadFiles()
}, [loadFiles])
scopeRef.current = scope
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
pendingDirectoryRetriesRef.current.clear()
directoryCacheRef.current = {}
setDirectoryCache({})
setExpanded(new Set())
setLoading(true)
setError(null)
}, [scope])
const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files])
useEffect(() => {
directoryCacheRef.current = directoryCache
}, [directoryCache])
const toggleDirectory = useCallback((relativePath: string) => {
triggerSelection()
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(relativePath)) {
next.delete(relativePath)
} else {
next.add(relativePath)
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)
}
return next
})
}, [])
},
[directoryCache, expanded, loadDirectory]
)
const retryDirectory = useCallback(
(relativePath: string) => {
if (connState !== 'connected' && hostId) {
pendingDirectoryRetriesRef.current.add(relativePath)
void forceReconnect(hostId)
return
}
void loadDirectory(relativePath)
},
[connState, forceReconnect, hostId, loadDirectory]
)
const previewFile = useCallback(
(relativePath: string, displayName: string) => {
triggerSelection()
navigateToMobileFilePreview(
router,
{
@ -116,66 +225,15 @@ export function MobileFileExplorerPanel(props: {
[embedded, hostId, name, onRequestClose, router, worktreeId]
)
const renderItem: ListRenderItem<TreeNode> = ({ item }) => {
const isDirectory = item.kind === 'directory'
const isExpanded = expanded.has(item.relativePath)
// Images render in the mobile viewer (via files.readPreview), so a binary
// image is openable; only non-previewable binaries are unavailable.
const previewable =
item.kind !== 'directory' &&
canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
const isImage = item.kind === 'binary' && previewable
const disabled = item.kind === 'binary' && !previewable
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
const renderItem: ListRenderItem<FileExplorerRow> = ({ item }) => {
return (
<Pressable
style={({ pressed }) => [
styles.row,
{ paddingLeft: spacing.lg + item.depth * 18 },
pressed && !disabled && styles.rowPressed,
disabled && styles.rowDisabled
]}
disabled={disabled}
onPress={() => {
if (isDirectory) {
toggleDirectory(item.relativePath)
} else if (!disabled) {
previewFile(item.relativePath, item.name)
}
}}
accessibilityLabel={
isDirectory
? `Open folder ${item.name}`
: disabled
? `${item.name} unavailable on mobile`
: `Preview file ${item.name}`
}
>
{isDirectory ? (
isExpanded ? (
<ChevronDown size={16} color={colors.textSecondary} />
) : (
<ChevronRight size={16} color={colors.textSecondary} />
)
) : (
<View style={styles.chevronSpacer} />
)}
{isDirectory ? (
<Folder size={17} color={colors.textSecondary} />
) : markdown ? (
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
) : isImage ? (
<ImageIcon size={17} color={colors.textSecondary} />
) : (
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
)}
<View style={styles.rowTextBlock}>
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
{item.name}
</Text>
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
</View>
</Pressable>
<MobileFileExplorerRow
item={item}
expanded={expanded}
onPreviewFile={previewFile}
onRetryDirectory={retryDirectory}
onToggleDirectory={toggleDirectory}
/>
)
}
@ -206,7 +264,6 @@ export function MobileFileExplorerPanel(props: {
</Text>
<Text style={styles.meta} numberOfLines={1}>
{worktreeLabel}
{truncated ? ' - Showing first 5000' : ''}
</Text>
</View>
</View>
@ -220,12 +277,12 @@ export function MobileFileExplorerPanel(props: {
<View style={styles.state}>
<Text style={styles.errorText}>{error}</Text>
{/* Why: while disconnected, re-sending the request is useless revive
the parked transport instead (issue #5049); loadFiles re-runs via
the parked transport instead (issue #5049); loadDirectory re-runs via
its effect once the new client connects. */}
<Pressable
style={styles.retryButton}
onPress={() =>
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles()
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('')
}
>
<Text style={styles.retryText}>Retry</Text>

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
} from './directory-load-revisions'
describe('directory-load-revisions', () => {
it('ignores stale duplicate loads for the same directory and scope', () => {
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
const older = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
const newer = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newer)).toBe(true)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', older)).toBe(false)
})
it('ignores loads from an old host/worktree scope', () => {
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
const oldScopeLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
expect(isCurrentDirectoryLoad(revisions, 'host-b:worktree-b', oldScopeLoad)).toBe(false)
})
it('ignores loads from an old reset generation in the same scope', () => {
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
const oldLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
resetDirectoryLoadRevisions(revisions)
const newLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newLoad)).toBe(true)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', oldLoad)).toBe(false)
})
it('tracks directory names that overlap object prototype keys', () => {
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
const load = beginDirectoryLoad(revisions, 'host-a:worktree-a', '__proto__')
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
})
})

View File

@ -0,0 +1,42 @@
export type DirectoryLoadRevisions = {
generation: number
revisionsByPath: Map<string, number>
}
export type DirectoryLoadToken = {
generation: number
relativePath: string
revision: number
scope: string
}
export function createDirectoryLoadRevisions(): DirectoryLoadRevisions {
return { generation: 0, revisionsByPath: new Map() }
}
export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): void {
revisions.generation += 1
revisions.revisionsByPath.clear()
}
export function beginDirectoryLoad(
revisions: DirectoryLoadRevisions,
scope: string,
relativePath: string
): DirectoryLoadToken {
const revision = (revisions.revisionsByPath.get(relativePath) ?? 0) + 1
revisions.revisionsByPath.set(relativePath, revision)
return { generation: revisions.generation, relativePath, revision, scope }
}
export function isCurrentDirectoryLoad(
revisions: DirectoryLoadRevisions,
currentScope: string,
token: DirectoryLoadToken
): boolean {
return (
currentScope === token.scope &&
revisions.generation === token.generation &&
revisions.revisionsByPath.get(token.relativePath) === token.revision
)
}

View File

@ -1,32 +1,109 @@
import { describe, expect, it } from 'vitest'
import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree'
import {
flattenDirectoryCache,
getMobileFileKind,
isMarkdownPath,
shouldIncludeMobileFileExplorerEntry,
type DirectoryCache,
type MobileDirEntry
} from './file-tree'
function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry {
return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind }
function entry(name: string, isDirectory = false, isSymlink = false): MobileDirEntry {
return { name, isDirectory, isSymlink }
}
describe('file-tree', () => {
it('nests files under their directories', () => {
const root = buildTree([entry('src/app.ts'), entry('src/lib/util.ts'), entry('readme.md')])
expect(root.files.map((f) => f.relativePath)).toEqual(['readme.md'])
expect(root.directories.get('src')?.directories.get('lib')?.files[0]?.relativePath).toBe(
'src/lib/util.ts'
)
it('flattens cached directories lazily with directories before files', () => {
const cache: DirectoryCache = {
'': { entries: [entry('zeta.txt'), entry('src', true), entry('readme.md')] },
src: { entries: [entry('app.ts'), entry('lib', true)] },
'src/lib': { entries: [entry('util.ts')] }
}
const collapsed = flattenDirectoryCache(cache, new Set())
expect(collapsed.map((row) => row.id)).toEqual(['dir:src', 'file:readme.md', 'file:zeta.txt'])
const expanded = flattenDirectoryCache(cache, new Set(['src', 'src/lib']))
expect(expanded.map((row) => row.id)).toEqual([
'dir:src',
'dir:src/lib',
'file:src/lib/util.ts',
'file:src/app.ts',
'file:readme.md',
'file:zeta.txt'
])
})
it('flattens with directories before files and only expands open dirs', () => {
const root = buildTree([entry('src/app.ts'), entry('zeta.txt')])
const collapsed = flattenTree(root, new Set())
expect(collapsed.map((r) => r.id)).toEqual(['dir:src', 'file:zeta.txt'])
it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => {
const cache: DirectoryCache = {
'': {
entries: [
entry('.git', true),
entry('.env'),
entry('node_modules', true),
entry('src', true)
]
}
}
const expanded = flattenTree(root, new Set(['src']))
expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt'])
expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([
'dir:src',
'file:.env'
])
expect(shouldIncludeMobileFileExplorerEntry(entry('.config', true))).toBe(true)
})
it('preserves the binary kind on flattened rows', () => {
const root = buildTree([entry('assets/logo.png', 'binary')])
const rows = flattenTree(root, new Set(['assets']))
expect(rows.find((r) => r.id === 'file:assets/logo.png')?.kind).toBe('binary')
it('renders inline loading and error rows under expanded directories', () => {
const cache: DirectoryCache = {
'': { entries: [entry('loading-dir', true), entry('error-dir', true)] },
'loading-dir': { entries: [], loading: true },
'error-dir': { entries: [], error: 'permission denied' }
}
const rows = flattenDirectoryCache(cache, new Set(['loading-dir', 'error-dir']))
expect(rows.map((row) => row.id)).toEqual([
'dir:error-dir',
'error:error-dir',
'dir:loading-dir',
'loading:loading-dir'
])
expect(rows.find((row) => row.id === 'error:error-dir')).toMatchObject({
kind: 'error',
message: 'permission denied'
})
})
it('preserves symlink metadata for user-initiated activation', () => {
const cache: DirectoryCache = {
'': { entries: [entry('linked-docs', false, true)] }
}
expect(flattenDirectoryCache(cache, new Set())).toEqual([
{
id: 'file:linked-docs',
name: 'linked-docs',
relativePath: 'linked-docs',
depth: 0,
kind: 'text',
isSymlink: true
}
])
})
it('does not treat inherited object keys as loaded directories', () => {
const cache: DirectoryCache = {
'': { entries: [entry('constructor', true), entry('__proto__', true)] },
['__proto__']: { entries: [entry('file.txt')] }
}
expect(
flattenDirectoryCache(cache, new Set(['constructor', '__proto__'])).map((row) => row.id)
).toEqual(['dir:__proto__', 'file:__proto__/file.txt', 'dir:constructor'])
})
it('classifies binary file paths for existing mobile preview behavior', () => {
expect(getMobileFileKind('assets/logo.png')).toBe('binary')
expect(getMobileFileKind('src/app.ts')).toBe('text')
})
it('detects markdown paths', () => {

View File

@ -1,91 +1,152 @@
// Pure tree model for the mobile file explorer: turns the flat files.list
// result into a nested directory structure and flattens it into renderable
// rows. Kept out of the screen component so the screen stays under its line cap.
// Pure tree projection for the mobile file explorer. Mobile mirrors desktop
// browse semantics by flattening cached files.readDir results as folders open.
export type MobileFileEntry = {
relativePath: string
basename: string
kind: 'text' | 'binary'
export type MobileDirEntry = {
name: string
isDirectory: boolean
isSymlink?: boolean
}
export type FilesListResult = {
files: MobileFileEntry[]
totalCount: number
truncated: boolean
}
export type MobileFileKind = 'text' | 'binary'
export type TreeNode = {
id: string
name: string
relativePath: string
depth: number
kind: 'directory' | 'text' | 'binary'
kind: 'directory' | MobileFileKind
isSymlink?: boolean
}
export type DirectoryNode = {
name: string
export type DirectoryState = {
entries: MobileDirEntry[]
loading?: boolean
error?: string
}
export type DirectoryCache = Record<string, DirectoryState | undefined>
export type InlineStatusNode = {
id: string
relativePath: string
directories: Map<string, DirectoryNode>
files: MobileFileEntry[]
depth: number
kind: 'loading' | 'error'
message?: string
}
function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
return { name, relativePath, directories: new Map(), files: [] }
}
export type FileExplorerRow = TreeNode | InlineStatusNode
export function buildTree(files: MobileFileEntry[]): DirectoryNode {
const root = createDirectoryNode('', '')
for (const file of files) {
const parts = file.relativePath.split('/').filter(Boolean)
let current = root
for (let index = 0; index < parts.length - 1; index += 1) {
const name = parts[index]!
const relativePath = parts.slice(0, index + 1).join('/')
let child = current.directories.get(name)
if (!child) {
child = createDirectoryNode(name, relativePath)
current.directories.set(name, child)
}
current = child
}
current.files.push(file)
}
return root
}
const DESKTOP_EXCLUDED_NAMES = new Set(['.git', 'node_modules'])
const BINARY_EXTENSIONS = new Set([
'.avif',
'.bmp',
'.gif',
'.heic',
'.ico',
'.jpeg',
'.jpg',
'.mov',
'.mp3',
'.mp4',
'.pdf',
'.png',
'.webp',
'.zip'
])
export function flattenTree(root: DirectoryNode, expanded: ReadonlySet<string>): TreeNode[] {
const rows: TreeNode[] = []
const visit = (directory: DirectoryNode, depth: number): void => {
const dirs = Array.from(directory.directories.values()).sort((a, b) =>
a.name.localeCompare(b.name)
)
for (const child of dirs) {
rows.push({
id: `dir:${child.relativePath}`,
name: child.name,
relativePath: child.relativePath,
depth,
kind: 'directory'
})
if (expanded.has(child.relativePath)) {
visit(child, depth + 1)
}
}
const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename))
for (const file of files) {
rows.push({
id: `file:${file.relativePath}`,
name: file.basename,
relativePath: file.relativePath,
depth,
kind: file.kind
})
}
}
visit(root, 0)
export function flattenDirectoryCache(
cache: DirectoryCache,
expanded: ReadonlySet<string>
): FileExplorerRow[] {
const rows: FileExplorerRow[] = []
visitDirectory('', 0, cache, expanded, rows)
return rows
}
function visitDirectory(
relativePath: string,
depth: number,
cache: DirectoryCache,
expanded: ReadonlySet<string>,
rows: FileExplorerRow[]
): void {
const state = getDirectoryCacheState(cache, relativePath)
const entries = state?.entries ?? []
const visibleEntries = entries
.filter(shouldIncludeMobileFileExplorerEntry)
.sort(compareDirectoryEntries)
for (const entry of visibleEntries) {
const childPath = joinRelativePath(relativePath, entry.name)
rows.push(toTreeNode(entry, childPath, depth))
if (entry.isDirectory && expanded.has(childPath)) {
const childState = getDirectoryCacheState(cache, childPath)
if (childState?.loading) {
rows.push({
id: `loading:${childPath}`,
relativePath: childPath,
depth: depth + 1,
kind: 'loading'
})
} else if (childState?.error) {
rows.push({
id: `error:${childPath}`,
relativePath: childPath,
depth: depth + 1,
kind: 'error',
message: childState.error
})
} else {
visitDirectory(childPath, depth + 1, cache, expanded, rows)
}
}
}
}
function toTreeNode(entry: MobileDirEntry, relativePath: string, depth: number): TreeNode {
return {
id: `${entry.isDirectory ? 'dir' : 'file'}:${relativePath}`,
name: entry.name,
relativePath,
depth,
kind: entry.isDirectory ? 'directory' : getMobileFileKind(relativePath),
isSymlink: entry.isSymlink
}
}
function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
}
export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean {
return !DESKTOP_EXCLUDED_NAMES.has(entry.name)
}
export function getDirectoryCacheState(
cache: DirectoryCache,
relativePath: string
): DirectoryState | undefined {
// Why: repository paths are arbitrary object keys; inherited keys like
// "constructor" must not masquerade as loaded directory state.
return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined
}
export function joinRelativePath(parentPath: string, name: string): string {
return parentPath ? `${parentPath}/${name}` : name
}
export function getMobileFileKind(relativePath: string): MobileFileKind {
const basename = relativePath.split('/').pop() ?? relativePath
const dotIndex = basename.lastIndexOf('.')
if (dotIndex <= 0) {
return 'text'
}
return BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) ? 'binary' : 'text'
}
export function isMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}

View File

@ -0,0 +1,145 @@
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import {
ChevronDown,
ChevronRight,
File,
FileText,
Folder,
Image as ImageIcon
} from 'lucide-react-native'
import { triggerSelection } from '../platform/haptics'
import { colors, spacing } from '../theme/mobile-theme'
import { type FileExplorerRow, isMarkdownPath, type TreeNode } from './file-tree'
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { canPreviewMobileFileRow } from './mobile-file-preview-navigation'
type Props = {
item: FileExplorerRow
expanded: ReadonlySet<string>
onPreviewFile: (relativePath: string, displayName: string) => void
onRetryDirectory: (relativePath: string) => void
onToggleDirectory: (relativePath: string) => void
}
export function MobileFileExplorerRow(props: Props) {
const { item, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } = props
if (item.kind === 'loading') {
return (
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
<View style={styles.chevronSpacer} />
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.inlineStatusText}>Loading...</Text>
</View>
)
}
if (item.kind === 'error') {
return (
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
<View style={styles.chevronSpacer} />
<Text style={styles.inlineErrorText} numberOfLines={1}>
{item.message || 'Unable to load folder'}
</Text>
<Pressable
style={({ pressed }) => [styles.inlineRetryButton, pressed && styles.rowPressed]}
onPress={() => {
triggerSelection()
onRetryDirectory(item.relativePath)
}}
accessibilityLabel={`Retry loading ${item.relativePath}`}
>
<Text style={styles.inlineRetryText}>Retry</Text>
</Pressable>
</View>
)
}
if (isTreeNode(item)) {
return (
<TreeRow
item={item}
expanded={expanded}
onPreviewFile={onPreviewFile}
onToggleDirectory={onToggleDirectory}
/>
)
}
return null
}
function isTreeNode(item: FileExplorerRow): item is TreeNode {
return item.kind === 'directory' || item.kind === 'text' || item.kind === 'binary'
}
function TreeRow(props: {
item: TreeNode
expanded: ReadonlySet<string>
onPreviewFile: (relativePath: string, displayName: string) => void
onToggleDirectory: (relativePath: string) => void
}) {
const { item, expanded, onPreviewFile, onToggleDirectory } = props
const isDirectory = item.kind === 'directory'
const isExpanded = expanded.has(item.relativePath)
// Images render in the mobile viewer (via files.readPreview), so a binary
// image is openable; only non-previewable binaries are unavailable.
const previewable =
item.kind !== 'directory' &&
canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
const isImage = item.kind === 'binary' && previewable
const disabled = item.kind === 'binary' && !previewable
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
return (
<Pressable
style={({ pressed }) => [
styles.row,
{ paddingLeft: spacing.lg + item.depth * 18 },
pressed && !disabled && styles.rowPressed,
disabled && styles.rowDisabled
]}
disabled={disabled}
onPress={() => {
triggerSelection()
if (isDirectory) {
onToggleDirectory(item.relativePath)
} else if (!disabled) {
onPreviewFile(item.relativePath, item.name)
}
}}
accessibilityLabel={
isDirectory
? `Open folder ${item.name}`
: disabled
? `${item.name} unavailable on mobile`
: `Preview file ${item.name}`
}
>
{isDirectory ? (
isExpanded ? (
<ChevronDown size={16} color={colors.textSecondary} />
) : (
<ChevronRight size={16} color={colors.textSecondary} />
)
) : (
<View style={styles.chevronSpacer} />
)}
{isDirectory ? (
<Folder size={17} color={colors.textSecondary} />
) : markdown ? (
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
) : isImage ? (
<ImageIcon size={17} color={colors.textSecondary} />
) : (
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
)}
<View style={styles.rowTextBlock}>
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
{item.name}
</Text>
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
</View>
</Pressable>
)
}

View File

@ -78,6 +78,37 @@ export const fileExplorerStyles = StyleSheet.create({
color: colors.textMuted,
fontSize: 11
},
inlineStatusRow: {
minHeight: 36,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.md
},
inlineStatusText: {
color: colors.textSecondary,
fontSize: typography.metaSize
},
inlineErrorText: {
flex: 1,
minWidth: 0,
color: colors.statusRed,
fontSize: typography.metaSize
},
inlineRetryButton: {
minHeight: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.md
},
inlineRetryText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
state: {
flex: 1,
alignItems: 'center',

View File

@ -153,6 +153,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'files.openDiff',
'files.read',
'files.readChunk',
'files.readDir',
'files.readPreview',
'files.readTerminalArtifact',
'files.readTerminalArtifactPreview',