Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions __tests__/integration/components/BrowseFiles.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react'
import { fireEvent, render, waitFor } from '@testing-library/react-native'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import BrowseScreen from '@/app/browse'
import { ThemeProvider } from '@/contexts/ThemeContext'

// The Explorer lists directories (navigable) and files (view-only). Files must
// render but never navigate — tapping one leaves the current directory intact.

jest.mock('expo-router', () => ({
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), back: jest.fn(), navigate: jest.fn() }),
useLocalSearchParams: () => ({ server: 'srv_alpha' }),
useGlobalSearchParams: () => ({}),
useNavigation: () => ({ setOptions: jest.fn(), addListener: jest.fn(() => jest.fn()) }),
useSegments: () => [],
router: { push: jest.fn(), replace: jest.fn(), back: jest.fn() },
Redirect: () => null,
Link: ({ children }: { children: React.ReactNode }) => children,
Stack: { Screen: () => null },
Tabs: { Screen: () => null },
}))

jest.mock('react-native-gesture-handler', () => {
const ReactLib = require('react')
const { View } = require('react-native')
const noop: any = {
activeOffsetX: () => noop,
failOffsetY: () => noop,
hitSlop: () => noop,
onEnd: () => noop,
}
return {
Gesture: { Pan: () => noop },
GestureDetector: ({ children }: { children: React.ReactNode }) =>
ReactLib.createElement(View, {}, children),
}
})

jest.mock('react-native-reanimated', () => ({
runOnJS: (fn: unknown) => fn,
}))

jest.mock('@/hooks/useBrowse', () => ({
useBrowse: () => ({
data: { path: '', directories: [{ name: 'src' }], files: [{ name: 'README.md' }] },
isLoading: false,
isError: false,
error: null,
}),
useCreateDirectory: () => ({ mutate: jest.fn(), isPending: false }),
}))

jest.mock('@/hooks/useSession', () => ({
useSessions: () => ({ data: [], refetch: jest.fn(), isPending: false }),
}))

async function renderScreen() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return await render(
<ThemeProvider>
<QueryClientProvider client={qc}>
<BrowseScreen />
</QueryClientProvider>
</ThemeProvider>,
)
}

describe('BrowseScreen — files in the Explorer', () => {
it('renders both directories and files', async () => {
const { getByTestId, getByText } = await renderScreen()
expect(getByTestId('browse-first-directory')).toBeTruthy()
expect(getByTestId('browse-file-README.md')).toBeTruthy()
expect(getByText('README.md')).toBeTruthy()
})

it('navigates into a directory when its row is tapped', async () => {
const { getByTestId } = await renderScreen()
fireEvent.press(getByTestId('browse-first-directory'))
await waitFor(() => expect(getByTestId('browse-cwd-src')).toBeTruthy())
})

it('does not navigate when a file row is tapped', async () => {
const { getByTestId } = await renderScreen()
fireEvent.press(getByTestId('browse-file-README.md'))
// cwd is unchanged — the file is view-only, not selectable.
expect(getByTestId('browse-cwd-~')).toBeTruthy()
})
})
43 changes: 33 additions & 10 deletions app/browse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { runOnJS } from 'react-native-reanimated'
import { FlashList } from '@shopify/flash-list'
import { SafeAreaView } from 'react-native-safe-area-context'
import { CaretDown, CaretRight, ClockCounterClockwise } from 'phosphor-react-native'
import { CaretDown, CaretRight, ClockCounterClockwise, File, Folder } from 'phosphor-react-native'
import { useBrowse, useCreateDirectory } from '@/hooks/useBrowse'
import { useSessions } from '@/hooks/useSession'
import { SkeletonBox } from '@/components/ui/Skeleton'
Expand All @@ -36,6 +36,8 @@ import { findProviderHealth } from '@/types/provider-health'
const MAX_RECENT_DIRS = 8
const PREVIEW_RECENT_DIRS = 3

type BrowseRow = { kind: 'dir' | 'file'; name: string }

export default function BrowseScreen() {
const theme = useTheme()
const isGlass = useIsGlass()
Expand Down Expand Up @@ -255,7 +257,18 @@ export default function BrowseScreen() {
)

const renderItem = useCallback(
({ item, index }: { item: { name: string }; index: number }) => {
({ item, index }: { item: BrowseRow; index: number }) => {
// Files are view-only: a plain row with no press handler and no chevron.
if (item.kind === 'file') {
return (
<View style={styles.row} testID={`browse-file-${item.name}`}>
<File size={20} color={theme.text.secondary} style={styles.rowIcon} />
<Text style={[styles.dirName, styles.fileName]} numberOfLines={1}>
{item.name}
</Text>
</View>
)
}
const childPath = currentPath ? `${currentPath}/${item.name}` : item.name
return (
<TouchableOpacity
Expand All @@ -265,17 +278,25 @@ export default function BrowseScreen() {
}}
testID={index === 0 ? "browse-first-directory" : undefined}
>
<Text style={styles.folderIcon}>📁</Text>
<Folder size={20} color={theme.text.accent} weight="fill" style={styles.rowIcon} />
<Text style={styles.dirName} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.chevron}>›</Text>
</TouchableOpacity>
)
},
[currentPath, navigateTo, styles],
[currentPath, navigateTo, styles, theme],
)

// Directories first (navigable), then files (view-only). Both arrive
// server-sorted; older servers omit `files`, so it coalesces to empty.
const rows = useMemo<BrowseRow[]>(() => {
const dirs = (data?.directories ?? []).map((d) => ({ kind: 'dir' as const, name: d.name }))
const files = (data?.files ?? []).map((f) => ({ kind: 'file' as const, name: f.name }))
return [...dirs, ...files]
}, [data])

const isBrowseNotConfigured = isError && (
(error instanceof NetworkError && error.code === 'BROWSE_ROOT_NOT_SET') ||
error?.message?.includes('not configured')
Expand Down Expand Up @@ -389,13 +410,13 @@ export default function BrowseScreen() {
title="Unable to load directories"
subtitle={error instanceof Error && error.message ? error.message : 'Unknown error'}
/>
) : data?.directories.length === 0 ? (
<EmptyState title="Empty directory" subtitle="No subdirectories here." />
) : rows.length === 0 ? (
<EmptyState title="Empty directory" subtitle="No files or folders here." />
) : (
<FlashList
data={data?.directories ?? []}
data={rows}
renderItem={renderItem}
keyExtractor={(item) => item.name}
keyExtractor={(item) => `${item.kind}:${item.name}`}
/>
)}
</View>
Expand Down Expand Up @@ -677,15 +698,17 @@ function makeStyles(theme: Theme) {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: theme.border,
},
folderIcon: {
fontSize: 20,
rowIcon: {
marginRight: spacing.md,
},
dirName: {
flex: 1,
color: theme.text.primary,
fontSize: font.base,
},
fileName: {
color: theme.text.secondary,
},
chevron: {
color: theme.text.secondary,
fontSize: font.xl,
Expand Down
3 changes: 3 additions & 0 deletions types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,9 @@ export interface PushRegisterPayload {
export interface BrowseResponse {
path: string
directories: { name: string }[]
// Optional so older servers (directories-only) still typecheck; the browse
// UI renders these read-only, they are not selectable.
files?: { name: string }[]
}

export interface MkdirResponse {
Expand Down
Loading