Show full workspace file tree on mobile (#7289)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
a393facef8
commit
99ae2dc90f
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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...')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue