mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user