From adf5ccbc927bf85351dc3c500c2e3f236a1eea6e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:30:05 -0700 Subject: [PATCH] feat(mobile): view image files from the Files tree (#5472) Co-authored-by: Orca --- mobile/app/h/[hostId]/files/[worktreeId].tsx | 122 +++++-------------- mobile/src/files/file-tree.test.ts | 37 ++++++ mobile/src/files/file-tree.ts | 91 ++++++++++++++ src/main/runtime/orca-runtime-files.test.ts | 35 ++++++ src/main/runtime/orca-runtime-files.ts | 36 +++++- src/shared/runtime-types.ts | 2 +- 6 files changed, 223 insertions(+), 100 deletions(-) create mode 100644 mobile/src/files/file-tree.test.ts create mode 100644 mobile/src/files/file-tree.ts diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx index 0bd81be07ec..9cad1ff33f1 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -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 - 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): 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 = ({ 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 ( { 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() { ) : markdown ? ( + ) : isImage ? ( + ) : ( )} diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/file-tree.test.ts new file mode 100644 index 00000000000..c4777ca2188 --- /dev/null +++ b/mobile/src/files/file-tree.test.ts @@ -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) + }) +}) diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts new file mode 100644 index 00000000000..e453cc152e4 --- /dev/null +++ b/mobile/src/files/file-tree.ts @@ -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 + 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): 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) +} diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 4c290052a7f..d02f97600b8 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -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') diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 1f755256eb7..39d54306286 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -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 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 = { '.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 } } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index a8d801a3efc..1880c0d575a 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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 }