feat(mobile): view image files from the Files tree (#5472)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-15 22:30:05 -07:00
committed by GitHub
co-authored by Orca
parent 1ccc07c013
commit adf5ccbc92
6 changed files with 223 additions and 100 deletions
+28 -94
View File
@@ -10,101 +10,30 @@ import {
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronDown, ChevronLeft, ChevronRight, File, FileText, Folder } from 'lucide-react-native'
import {
ChevronDown,
ChevronLeft,
ChevronRight,
File,
FileText,
Folder,
Image as ImageIcon
} from 'lucide-react-native'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import { getWorktreeLabel } from '../../../../src/session/worktree-label'
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
import {
buildTree,
flattenTree,
isMarkdownPath,
type FilesListResult,
type MobileFileEntry,
type TreeNode
} from '../../../../src/files/file-tree'
import type { RpcSuccess } from '../../../../src/transport/types'
import { triggerError, triggerSelection } from '../../../../src/platform/haptics'
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
type MobileFileEntry = {
relativePath: string
basename: string
kind: 'text' | 'binary'
}
type FilesListResult = {
files: MobileFileEntry[]
totalCount: number
truncated: boolean
}
type TreeNode = {
id: string
name: string
relativePath: string
depth: number
kind: 'directory' | 'text' | 'binary'
}
type DirectoryNode = {
name: string
relativePath: string
directories: Map<string, DirectoryNode>
files: MobileFileEntry[]
}
function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
return { name, relativePath, directories: new Map(), files: [] }
}
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
}
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)
return rows
}
function isMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}
export default function MobileFileExplorerScreen() {
const { hostId, worktreeId, name } = useLocalSearchParams<{
hostId: string
@@ -165,8 +94,8 @@ export default function MobileFileExplorerScreen() {
}, [])
const openFile = useCallback(
async (relativePath: string, kind: 'text' | 'binary') => {
if (!client || kind === 'binary') {
async (relativePath: string) => {
if (!client) {
return
}
setOpeningPath(relativePath)
@@ -193,7 +122,10 @@ export default function MobileFileExplorerScreen() {
const renderItem: ListRenderItem<TreeNode> = ({ item }) => {
const isDirectory = item.kind === 'directory'
const isExpanded = expanded.has(item.relativePath)
const disabled = item.kind === 'binary'
// Images render in the mobile viewer (via files.readPreview), so a binary
// image is openable; only non-previewable binaries are unavailable.
const isImage = item.kind === 'binary' && classifyMobileArtifact(item.relativePath) === 'image'
const disabled = item.kind === 'binary' && !isImage
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
return (
<Pressable
@@ -207,8 +139,8 @@ export default function MobileFileExplorerScreen() {
onPress={() => {
if (isDirectory) {
toggleDirectory(item.relativePath)
} else if (item.kind === 'text' || item.kind === 'binary') {
void openFile(item.relativePath, item.kind)
} else if (!disabled) {
void openFile(item.relativePath)
}
}}
accessibilityLabel={
@@ -232,6 +164,8 @@ export default function MobileFileExplorerScreen() {
<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} />
)}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree'
function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry {
return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind }
}
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 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'])
const expanded = flattenTree(root, new Set(['src']))
expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt'])
})
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('detects markdown paths', () => {
expect(isMarkdownPath('docs/readme.md')).toBe(true)
expect(isMarkdownPath('notes.markdown')).toBe(true)
expect(isMarkdownPath('app.ts')).toBe(false)
})
})
+91
View File
@@ -0,0 +1,91 @@
// 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.
export type MobileFileEntry = {
relativePath: string
basename: string
kind: 'text' | 'binary'
}
export type FilesListResult = {
files: MobileFileEntry[]
totalCount: number
truncated: boolean
}
export type TreeNode = {
id: string
name: string
relativePath: string
depth: number
kind: 'directory' | 'text' | 'binary'
}
export type DirectoryNode = {
name: string
relativePath: string
directories: Map<string, DirectoryNode>
files: MobileFileEntry[]
}
function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
return { name, relativePath, directories: new Map(), files: [] }
}
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
}
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)
return rows
}
export function isMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}
@@ -223,6 +223,41 @@ describe('RuntimeFileCommands', () => {
})
})
it('opens previewable images through the renderer host as an image tab', async () => {
const openFile = vi.fn()
const { commands } = createRuntimeFileCommands({ openFile })
const result = await commands.openMobileFile('id:wt-1', 'assets/logo.png')
expect(openFile).toHaveBeenCalledWith(
'wt-1',
'/repo/assets/logo.png',
'assets/logo.png',
undefined
)
expect(result).toEqual({
worktree: 'wt-1',
relativePath: 'assets/logo.png',
kind: 'image',
opened: true
})
})
it('leaves non-previewable binaries unavailable on mobile', async () => {
const openFile = vi.fn()
const { commands } = createRuntimeFileCommands({ openFile })
const result = await commands.openMobileFile('id:wt-1', 'dist/bundle.zip')
expect(openFile).not.toHaveBeenCalled()
expect(result).toEqual({
worktree: 'wt-1',
relativePath: 'dist/bundle.zip',
kind: 'binary',
opened: false
})
})
it('does not follow symlinks when reading runtime-local file explorer dirs', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockResolvedValue('/repo')
+31 -5
View File
@@ -87,6 +87,28 @@ const MOBILE_BINARY_EXTENSIONS = new Set([
'.webp',
'.zip'
])
// Raster image extensions the mobile client can render from a base64 data URI
// via files.readPreview. Mirrors mobile's classifyMobileArtifact image set;
// SVG/PDF are intentionally excluded (RN <Image> can't decode those data URIs).
const MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.bmp',
'.ico'
])
function isMobilePreviewableImagePath(relativePath: string): boolean {
const basename = basenameFromRelativePath(relativePath)
const dotIndex = basename.lastIndexOf('.')
if (dotIndex <= 0) {
return false
}
return MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase())
}
const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
@@ -182,11 +204,15 @@ export class RuntimeFileCommands {
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
const kind = isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
// Previewable images open like text (the mobile viewer renders them via
// files.readPreview); other binaries stay unavailable on mobile.
const kind = isMobilePreviewableImagePath(relativePath)
? 'image'
: isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
if (kind === 'binary') {
return { worktree: worktree.id, relativePath, kind, opened: false }
}
+1 -1
View File
@@ -284,7 +284,7 @@ export type RuntimeFileListResult = {
export type RuntimeFileOpenResult = {
worktree: string
relativePath: string
kind: 'markdown' | 'text' | 'binary'
kind: 'markdown' | 'text' | 'binary' | 'image'
opened: boolean
}