Add mobile file explorer

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-12 17:26:49 -04:00
co-authored by Orca
parent dc54db228b
commit 7f75f4f617
12 changed files with 789 additions and 11 deletions
@@ -0,0 +1,399 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
View,
type ListRenderItem
} 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 { useHostClient } from '../../../../src/transport/client-context'
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
worktreeId: string
name?: string
}>()
const router = useRouter()
const { client, state: connState } = useHostClient(hostId)
const [files, setFiles] = useState<MobileFileEntry[]>([])
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [openingPath, setOpeningPath] = useState<string | null>(null)
const [truncated, setTruncated] = useState(false)
const loadFiles = useCallback(async () => {
if (!client || connState !== 'connected') return
setLoading(true)
setError(null)
try {
const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` })
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to load files')
}
const result = (response as RpcSuccess).result as FilesListResult
setFiles(result.files)
setTruncated(result.truncated)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load files')
} finally {
setLoading(false)
}
}, [client, connState, worktreeId])
useEffect(() => {
void loadFiles()
}, [loadFiles])
const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files])
const toggleDirectory = useCallback((relativePath: string) => {
triggerSelection()
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(relativePath)) {
next.delete(relativePath)
} else {
next.add(relativePath)
}
return next
})
}, [])
const openFile = useCallback(
async (relativePath: string, kind: 'text' | 'binary') => {
if (!client || kind === 'binary') return
setOpeningPath(relativePath)
try {
const response = await client.sendRequest('files.open', {
worktree: `id:${worktreeId}`,
relativePath
})
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to open file')
}
triggerSelection()
router.replace({
pathname: '/h/[hostId]/session/[worktreeId]',
params: { hostId, worktreeId, name: name ?? '' }
})
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Unable to open file')
} finally {
setOpeningPath(null)
}
},
[client, hostId, name, router, worktreeId]
)
const renderItem: ListRenderItem<TreeNode> = ({ item }) => {
const isDirectory = item.kind === 'directory'
const isExpanded = expanded.has(item.relativePath)
const disabled = item.kind === 'binary'
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
return (
<Pressable
style={({ pressed }) => [
styles.row,
{ paddingLeft: spacing.lg + item.depth * 18 },
pressed && !disabled && styles.rowPressed,
disabled && styles.rowDisabled
]}
disabled={disabled || openingPath !== null}
onPress={() => {
if (isDirectory) {
toggleDirectory(item.relativePath)
} else {
void openFile(item.relativePath, item.kind)
}
}}
accessibilityLabel={isDirectory ? `Open folder ${item.name}` : `Open file ${item.name}`}
>
{isDirectory ? (
isExpanded ? (
<ChevronDown size={16} color={colors.textSecondary} />
) : (
<ChevronRight size={16} color={colors.textSecondary} />
)
) : (
<View style={styles.chevronSpacer} />
)}
{isDirectory ? (
<Folder size={17} color={colors.textSecondary} />
) : markdown ? (
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
) : (
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
)}
<View style={styles.rowTextBlock}>
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
{item.name}
</Text>
{disabled ? <Text style={styles.rowMeta}>Read-only on mobile</Text> : null}
</View>
{openingPath === item.relativePath ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</Pressable>
)
}
return (
<View style={styles.container}>
<SafeAreaView style={styles.header} edges={['top']}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={() => router.back()}
hitSlop={8}
accessibilityLabel="Back to session"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<View style={styles.titleBlock}>
<Text style={styles.title} numberOfLines={1}>
Files
</Text>
<Text style={styles.meta} numberOfLines={1}>
{name || 'Active worktree'}
{truncated ? ' - Showing first 5000' : ''}
</Text>
</View>
</View>
</SafeAreaView>
{loading ? (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : error ? (
<View style={styles.state}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={() => void loadFiles()}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
) : rows.length === 0 ? (
<View style={styles.state}>
<Text style={styles.emptyText}>No files found</Text>
</View>
) : (
<FlatList
data={rows}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
style={styles.list}
/>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
header: {
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
topBar: {
minHeight: 58,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.md
},
backButton: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
titleBlock: {
flex: 1,
minWidth: 0
},
title: {
color: colors.textPrimary,
fontSize: typography.titleSize,
fontWeight: '600'
},
meta: {
marginTop: 2,
color: colors.textSecondary,
fontSize: typography.metaSize
},
list: {
flex: 1
},
listContent: {
paddingVertical: spacing.sm
},
row: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.md
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowDisabled: {
opacity: 0.58
},
chevronSpacer: {
width: 16
},
rowTextBlock: {
flex: 1,
minWidth: 0
},
rowTitle: {
color: colors.textPrimary,
fontSize: typography.bodySize
},
rowTitleDisabled: {
color: colors.textMuted
},
rowMeta: {
marginTop: 1,
color: colors.textMuted,
fontSize: 11
},
state: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.md,
padding: spacing.xl
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center'
},
retryButton: {
minHeight: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.lg
},
retryText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
}
})
+77 -6
View File
@@ -21,6 +21,8 @@ import {
ArrowUp,
ChevronLeft,
Eraser,
Folder,
File,
FileText,
Monitor,
Plus,
@@ -79,13 +81,22 @@ type MobileSessionTab =
isActive: boolean
documentVersion: string
}
| {
type: 'file'
id: string
title: string
filePath: string
relativePath: string
isDirty: boolean
isActive: boolean
}
type SessionTabsResult = {
worktree: string
snapshotVersion: number
tabs: MobileSessionTab[]
activeTabId: string | null
activeTabType: 'terminal' | 'markdown' | null
activeTabType: 'terminal' | 'markdown' | 'file' | null
}
type MarkdownDocState =
@@ -397,7 +408,7 @@ export default function SessionScreen() {
// and may not render reliably.
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
const activeSessionTabTypeRef = useRef<'terminal' | 'markdown' | null>(null)
const activeSessionTabTypeRef = useRef<'terminal' | 'markdown' | 'file' | null>(null)
const pendingActiveSessionTabIdRef = useRef<string | null>(null)
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
@@ -886,7 +897,7 @@ export default function SessionScreen() {
activeHandleRef.current = active.terminal
setActiveHandle(active.terminal)
subscribeToTerminal(active.terminal)
} else if (active?.type === 'markdown') {
} else if (active) {
const previous = activeHandleRef.current
if (previous) {
unsubscribeTerminal(previous)
@@ -1399,7 +1410,7 @@ export default function SessionScreen() {
triggerSelection()
pendingActiveSessionTabIdRef.current = tab.id
activeSessionTabTypeRef.current = 'markdown'
activeSessionTabTypeRef.current = tab.type
setActiveSessionTabId(tab.id)
const prev = activeHandleRef.current
if (prev) {
@@ -1416,6 +1427,9 @@ export default function SessionScreen() {
})
.catch(() => {})
}
if (tab.type === 'file') {
return
}
const cached = markdownDocs.get(tab.id)
if (cached?.status === 'ready' && cached.isDirty) {
return
@@ -1840,6 +1854,7 @@ export default function SessionScreen() {
isActive: terminal.handle === activeHandle
}))
const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null
const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null
const showLoadingState = connState === 'connected' && !terminalsLoaded && visibleTabs.length === 0
const showEmptyState =
connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle
@@ -1904,6 +1919,19 @@ export default function SessionScreen() {
</Text>
</View>
</View>
<Pressable
style={({ pressed }) => [styles.filesButton, pressed && styles.filesButtonPressed]}
onPress={() =>
router.push({
pathname: '/h/[hostId]/files/[worktreeId]',
params: { hostId, worktreeId, name: worktreeName || name || '' }
})
}
hitSlop={8}
accessibilityLabel="Open file explorer"
>
<Folder size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
</View>
{visibleTabs.length > 0 && (
@@ -1932,7 +1960,7 @@ export default function SessionScreen() {
title: t.title,
isActive: t.terminal === activeHandle
})
} else {
} else if (t.type === 'markdown') {
setMarkdownActionTarget(t)
}
}}
@@ -1942,6 +1970,9 @@ export default function SessionScreen() {
{t.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
<Text
style={[
styles.tabText,
@@ -2010,6 +2041,16 @@ export default function SessionScreen() {
</Animated.View>
)}
</View>
) : activeFileTab ? (
<View style={styles.fileTabFrame}>
<File size={28} color={colors.textSecondary} strokeWidth={1.8} />
<Text style={styles.fileTabTitle} numberOfLines={1}>
{activeFileTab.title || 'File'}
</Text>
<Text style={styles.fileTabMeta} numberOfLines={2}>
Opened on desktop
</Text>
</View>
) : (
<View
style={styles.terminalFrame}
@@ -2046,7 +2087,7 @@ export default function SessionScreen() {
{/* Why: translate instead of resizing so keyboard open/close does not
trigger a server-side PTY viewport change. */}
{!activeMarkdownTab && (
{!activeMarkdownTab && !activeFileTab && (
<View
style={[
styles.commandDock,
@@ -2385,6 +2426,17 @@ const styles = StyleSheet.create({
backButtonPressed: {
backgroundColor: colors.bgRaised
},
filesButton: {
width: 36,
height: 36,
borderRadius: radii.button,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs
},
filesButtonPressed: {
backgroundColor: colors.bgRaised
},
sessionTitleBlock: {
flex: 1,
minWidth: 0
@@ -2480,6 +2532,25 @@ const styles = StyleSheet.create({
minHeight: 0,
backgroundColor: colors.bgBase
},
fileTabFrame: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.xl,
backgroundColor: colors.bgBase
},
fileTabTitle: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600',
maxWidth: '100%'
},
fileTabMeta: {
color: colors.textSecondary,
fontSize: typography.metaSize,
textAlign: 'center'
},
markdownEditor: {
flex: 1,
position: 'relative'
+123 -2
View File
@@ -10,7 +10,7 @@ import type { AgentStatus } from '../../shared/agent-detection'
import { gitExecFileAsync } from '../git/runner'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
import { randomUUID } from 'crypto'
import { join } from 'path'
import { join, posix, win32 } from 'path'
import { rm } from 'fs/promises'
import { OrchestrationDb } from './orchestration/db'
import { formatMessagesForInjection } from './orchestration/formatter'
@@ -56,6 +56,8 @@ import type {
RuntimeMobileSessionMarkdownTab,
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot,
RuntimeFileListResult,
RuntimeFileOpenResult,
RuntimeSyncWindowGraph,
RuntimeWorktreeListResult,
BrowserSnapshotResult,
@@ -119,6 +121,7 @@ import {
getRecentDriftSubjects
} from '../git/repo'
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
import {
createSetupRunnerScript,
getEffectiveHooks,
@@ -148,6 +151,7 @@ import { HeadlessEmulator } from '../daemon/headless-emulator'
import { killAllProcessesForWorktree } from './worktree-teardown'
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
import type { IPtyProvider } from '../providers/types'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import type { ClaudeAccountService } from '../claude-accounts/service'
import type { CodexAccountService } from '../codex-accounts/service'
import type { RateLimitService } from '../rate-limits/service'
@@ -269,6 +273,24 @@ type RuntimePtyController = {
getSize?(ptyId: string): { cols: number; rows: number } | null
}
const MOBILE_FILE_LIST_LIMIT = 5000
const MOBILE_BINARY_EXTENSIONS = new Set([
'.avif',
'.bmp',
'.gif',
'.heic',
'.ico',
'.jpeg',
'.jpg',
'.mov',
'.mp3',
'.mp4',
'.pdf',
'.png',
'.webp',
'.zip'
])
type RuntimeNotifier = {
worktreesChanged(repoId: string): void
worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void
@@ -296,6 +318,7 @@ type RuntimeNotifier = {
renameTerminal(tabId: string, title: string | null): void
focusTerminal(tabId: string, worktreeId: string): void
focusEditorTab?(tabId: string, worktreeId: string): void
openFile?(worktreeId: string, filePath: string, relativePath: string): void
readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise<RuntimeMarkdownReadTabResult>
saveMobileMarkdownTab?(
worktreeId: string,
@@ -923,6 +946,70 @@ export class OrcaRuntimeService {
return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content)
}
async listMobileFiles(worktreeSelector: string): Promise<RuntimeFileListResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
const repo = this.store.getRepo(worktree.repoId)
const connectionId = repo?.connectionId ?? undefined
const files = connectionId
? await this.listRemoteMobileFiles(worktree.path, connectionId)
: await listQuickOpenFiles(worktree.path, this.store as unknown as Store)
const entries = files
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
.sort((a, b) => a.localeCompare(b))
.slice(0, MOBILE_FILE_LIST_LIMIT)
.map((relativePath) => ({
relativePath,
basename: basenameFromRelativePath(relativePath),
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
}))
return {
worktree: worktree.id,
rootPath: worktree.path,
files: entries,
totalCount: files.length,
truncated: files.length > MOBILE_FILE_LIST_LIMIT
}
}
async openMobileFile(
worktreeSelector: string,
relativePath: string
): Promise<RuntimeFileOpenResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
const kind = isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
if (kind === 'binary') {
return { worktree: worktree.id, relativePath, kind, opened: false }
}
if (!this.notifier?.openFile) {
throw new Error('renderer_unavailable')
}
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
this.notifier.openFile(worktree.id, filePath, relativePath)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise<string[]> {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
return []
}
return provider.listFiles(rootPath)
}
onMobileSessionTabsChanged(
listener: (snapshot: RuntimeMobileSessionTabsResult) => void
): () => void {
@@ -4995,7 +5082,7 @@ export class OrcaRuntimeService {
): RuntimeMobileSessionTabsResult {
const tabs: RuntimeMobileSessionClientTab[] = []
for (const tab of snapshot.tabs) {
if (tab.type === 'markdown') {
if (tab.type === 'markdown' || tab.type === 'file') {
tabs.push(tab)
continue
}
@@ -7240,6 +7327,40 @@ function maxTimestamp(left: number | null, right: number | null): number | null
return Math.max(left, right)
}
function isSafeMobileRelativePath(relativePath: string): boolean {
if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) {
return false
}
const parts = relativePath.replace(/\\/g, '/').split('/')
return parts.every((part) => part !== '' && part !== '.' && part !== '..')
}
function isMobileMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}
function isMobileBinaryPath(relativePath: string): boolean {
const basename = basenameFromRelativePath(relativePath)
const dotIndex = basename.lastIndexOf('.')
if (dotIndex <= 0) {
return false
}
return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase())
}
function basenameFromRelativePath(relativePath: string): string {
const normalized = relativePath.replace(/\\/g, '/')
return normalized.slice(normalized.lastIndexOf('/') + 1)
}
function joinWorktreeRelativePath(rootPath: string, relativePath: string): string {
const normalizedRelativePath = relativePath.replace(/\\/g, '/')
if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) {
return win32.join(rootPath, ...normalizedRelativePath.split('/'))
}
return posix.join(rootPath, ...normalizedRelativePath.split('/'))
}
function compareWorktreePs(
left: RuntimeWorktreePsSummary,
right: RuntimeWorktreePsSummary
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { FILE_METHODS } from './files'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('file RPC methods', () => {
it('lists files for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listMobileFiles: vi.fn().mockResolvedValue({
worktree: 'wt-1',
rootPath: '/repo',
files: [],
totalCount: 0,
truncated: false
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(makeRequest('files.list', { worktree: 'id:wt-1' }))
expect(runtime.listMobileFiles).toHaveBeenCalledWith('id:wt-1')
expect(response).toMatchObject({
ok: true,
result: { worktree: 'wt-1', files: [] }
})
})
it('opens a relative file path for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
openMobileFile: vi.fn().mockResolvedValue({
worktree: 'wt-1',
relativePath: 'docs/readme.md',
kind: 'markdown',
opened: true
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'docs/readme.md' })
)
expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md')
expect(response).toMatchObject({
ok: true,
result: { kind: 'markdown', opened: true }
})
})
})
+30
View File
@@ -0,0 +1,30 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
const WorktreeSelector = z.object({
worktree: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing worktree selector'))
})
const FileOpen = WorktreeSelector.extend({
relativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing relative path'))
})
export const FILE_METHODS: RpcMethod[] = [
defineMethod({
name: 'files.list',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.listMobileFiles(params.worktree)
}),
defineMethod({
name: 'files.open',
params: FileOpen,
handler: async (params, { runtime }) =>
runtime.openMobileFile(params.worktree, params.relativePath)
})
]
+3 -1
View File
@@ -11,6 +11,7 @@ import { STATS_METHODS } from './stats'
import { ACCOUNT_METHODS } from './accounts'
import { COMPUTER_METHODS } from './computer'
import { SESSION_TAB_METHODS } from './session-tabs'
import { FILE_METHODS } from './files'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@@ -27,5 +28,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...STATS_METHODS,
...ACCOUNT_METHODS,
...COMPUTER_METHODS,
...SESSION_TAB_METHODS
...SESSION_TAB_METHODS,
...FILE_METHODS
]
@@ -276,6 +276,8 @@ function registerRuntimeWindowLifecycle(
renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }),
focusTerminal: (tabId, worktreeId) => send('ui:focusTerminal', { tabId, worktreeId }),
focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }),
openFile: (worktreeId, filePath, relativePath) =>
send('ui:openFileFromMobile', { worktreeId, filePath, relativePath }),
readMobileMarkdownTab: (worktreeId, tabId) =>
requestMobileMarkdownFromRenderer(mainWindow, {
operation: 'read',
+3
View File
@@ -1127,6 +1127,9 @@ export type PreloadApi = {
onFocusEditorTab: (
callback: (data: { tabId: string; worktreeId: string }) => void
) => () => void
onOpenFileFromMobile: (
callback: (data: { worktreeId: string; filePath: string; relativePath: string }) => void
) => () => void
onMobileMarkdownRequest: (
callback: (request: RuntimeMobileMarkdownRequest) => void
) => () => void
+10
View File
@@ -1946,6 +1946,16 @@ const api = {
ipcRenderer.on('ui:focusEditorTab', listener)
return () => ipcRenderer.removeListener('ui:focusEditorTab', listener)
},
onOpenFileFromMobile: (
callback: (data: { worktreeId: string; filePath: string; relativePath: string }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { worktreeId: string; filePath: string; relativePath: string }
) => callback(data)
ipcRenderer.on('ui:openFileFromMobile', listener)
return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener)
},
onMobileMarkdownRequest: (
callback: (request: RuntimeMobileMarkdownRequest) => void
): (() => void) => {
+23
View File
@@ -29,6 +29,7 @@ import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-
import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state'
import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry'
import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
import { detectLanguage } from '@/lib/language-detect'
export { resolveZoomTarget } from './resolve-zoom-target'
@@ -369,6 +370,28 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.ui.onOpenFileFromMobile(({ worktreeId, filePath, relativePath }) => {
const store = useAppStore.getState()
const basename = relativePath.split(/[\\/]/).pop() || relativePath
store.setActiveWorktree(worktreeId)
store.markWorktreeVisited(worktreeId)
store.setActiveView('terminal')
// Why: mobile only sends a desktop-backed path. The renderer owns
// editor tab creation so grouped tab order and markdown bridges update
// through the same store path as desktop File Explorer.
store.openFile({
filePath,
relativePath,
worktreeId,
language: detectLanguage(basename),
mode: 'edit'
})
store.setActiveTabType('editor')
store.revealWorktreeInSidebar(worktreeId)
})
)
unsubs.push(
window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => {
if (paneRuntimeId != null) {
@@ -3,6 +3,7 @@ import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/termina
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { AppState } from '@/store/types'
import type {
RuntimeMobileSessionFileTab,
RuntimeMobileSessionMarkdownTab,
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabsSnapshot,
@@ -213,6 +214,8 @@ function buildMobileSessionTabSnapshots(state: AppState): RuntimeMobileSessionTa
const markdown = file ? buildMobileMarkdownTab(state, file.id, item.tabId) : null
if (markdown) {
tabs.push(markdown)
} else if (file) {
tabs.push(buildMobileFileTab(state, file.id, item.tabId))
}
}
}
@@ -277,6 +280,30 @@ function buildMobileMarkdownTab(
}
}
function buildMobileFileTab(
state: AppState,
fileId: string,
unifiedTabId?: string
): RuntimeMobileSessionFileTab {
const file = state.openFiles.find((candidate) => candidate.id === fileId)!
const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File'
return {
type: 'file',
id: unifiedTabId ?? file.id,
title,
filePath: file.filePath,
relativePath: file.relativePath,
language: file.language,
isDirty: file.isDirty,
isActive: unifiedTabId
? state.groupsByWorktree[file.worktreeId]?.some(
(group) => group.activeTabId === unifiedTabId
) === true
: state.activeFileId === file.id
}
}
function stableHashString(value: string): string {
let hash = 2166136261
for (let i = 0; i < value.length; i += 1) {
+36 -2
View File
@@ -94,9 +94,21 @@ export type RuntimeMobileSessionMarkdownTab = {
documentVersion: string
}
export type RuntimeMobileSessionFileTab = {
type: 'file'
id: string
title: string
filePath: string
relativePath: string
language: string
isDirty: boolean
isActive: boolean
}
export type RuntimeMobileSessionSnapshotTab =
| RuntimeMobileSessionTerminalTab
| RuntimeMobileSessionMarkdownTab
| RuntimeMobileSessionFileTab
export type RuntimeMobileSessionTerminalClientTab = Omit<
RuntimeMobileSessionTerminalTab,
@@ -108,13 +120,14 @@ export type RuntimeMobileSessionTerminalClientTab = Omit<
export type RuntimeMobileSessionClientTab =
| RuntimeMobileSessionTerminalClientTab
| RuntimeMobileSessionMarkdownTab
| RuntimeMobileSessionFileTab
export type RuntimeMobileSessionTabsSnapshot = {
worktree: string
snapshotVersion: number
activeGroupId: string | null
activeTabId: string | null
activeTabType: 'terminal' | 'markdown' | null
activeTabType: 'terminal' | 'markdown' | 'file' | null
tabs: RuntimeMobileSessionSnapshotTab[]
}
@@ -123,10 +136,31 @@ export type RuntimeMobileSessionTabsResult = {
snapshotVersion: number
activeGroupId: string | null
activeTabId: string | null
activeTabType: 'terminal' | 'markdown' | null
activeTabType: 'terminal' | 'markdown' | 'file' | null
tabs: RuntimeMobileSessionClientTab[]
}
export type RuntimeFileListEntry = {
relativePath: string
basename: string
kind: 'text' | 'binary'
}
export type RuntimeFileListResult = {
worktree: string
rootPath: string
files: RuntimeFileListEntry[]
totalCount: number
truncated: boolean
}
export type RuntimeFileOpenResult = {
worktree: string
relativePath: string
kind: 'markdown' | 'text' | 'binary'
opened: boolean
}
export type RuntimeTerminalSummary = {
handle: string
worktreeId: string