From 8f3c075fc3fefc6703f7a0b4387dd198e8112d7d Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Wed, 12 Aug 2026 17:26:49 +0300 Subject: [PATCH] feat(browse): show files in the Explorer as view-only rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browse Explorer only listed directories, so files in a folder were invisible even though a user often needs to see them before starting a session. Render files below directories as plain, non-interactive rows — no press handler, no chevron, dimmed text — while directories stay tappable for navigation. `BrowseResponse.files` is optional, so the screen degrades to directories-only against a server that does not send files yet. Swap the folder emoji for the Phosphor Folder and File icons to match the project icon convention. --- .../components/BrowseFiles.test.tsx | 90 +++++++++++++++++++ app/browse.tsx | 43 ++++++--- types/api.ts | 3 + 3 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 __tests__/integration/components/BrowseFiles.test.tsx 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 {