diff --git a/__tests__/integration/components/BrowseFiles.test.tsx b/__tests__/integration/components/BrowseFiles.test.tsx
new file mode 100644
index 00000000..d53b980b
--- /dev/null
+++ b/__tests__/integration/components/BrowseFiles.test.tsx
@@ -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(
+
+
+
+
+ ,
+ )
+}
+
+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()
+ })
+})
diff --git a/app/browse.tsx b/app/browse.tsx
index 493b9bfb..4e3a97cc 100644
--- a/app/browse.tsx
+++ b/app/browse.tsx
@@ -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'
@@ -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()
@@ -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 (
+
+
+
+ {item.name}
+
+
+ )
+ }
const childPath = currentPath ? `${currentPath}/${item.name}` : item.name
return (
- 📁
+
{item.name}
@@ -273,9 +286,17 @@ export default function BrowseScreen() {
)
},
- [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(() => {
+ 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')
@@ -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 ? (
-
+ ) : rows.length === 0 ? (
+
) : (
item.name}
+ keyExtractor={(item) => `${item.kind}:${item.name}`}
/>
)}
@@ -677,8 +698,7 @@ function makeStyles(theme: Theme) {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: theme.border,
},
- folderIcon: {
- fontSize: 20,
+ rowIcon: {
marginRight: spacing.md,
},
dirName: {
@@ -686,6 +706,9 @@ function makeStyles(theme: Theme) {
color: theme.text.primary,
fontSize: font.base,
},
+ fileName: {
+ color: theme.text.secondary,
+ },
chevron: {
color: theme.text.secondary,
fontSize: font.xl,
diff --git a/types/api.ts b/types/api.ts
index dde24639..c523a196 100644
--- a/types/api.ts
+++ b/types/api.ts
@@ -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 {