mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(mobile): view image files from the Files tree (#5472)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user